/ 19 min read

Inside Claude Code: React in Your Terminal

How Claude Code uses React and Ink to bring component-based UI to the terminal — 140+ components powered by familiar patterns. Part 8 of 10.

What If JSX Rendered to Your Terminal?

What if you could use useState, useEffect, and JSX components — but instead of rendering to a browser DOM, they render colored text in your terminal? That is exactly what Claude Code does.

Every time you see a streaming response, a permission prompt, a diff preview, or a progress spinner in Claude Code, you are looking at a React component tree. Not a web browser. Not Electron. A terminal. The same mental model — props flow down, state changes trigger re-renders, composition beats inheritance — drives a 140+ component interface that updates at 60fps on raw ANSI output.

I wanted to understand why that choice was made, how the rendering pipeline actually works, what patterns make it effective, and where the approach shows its rough edges.

The Problem: CLI UIs Get Unmanageable Fast

Building a rich command-line interface sounds simple until you start listing requirements. Claude Code needs:

  • Streaming text that renders token-by-token as the model generates output, with Markdown formatting, syntax highlighting, and word wrapping that adjust on the fly.
  • Interactive prompts for permission requests, file edit approvals, API key entry, and multi-select dialogs — all requiring focus management, keyboard input handling, and state that changes in response to user actions.
  • Dynamic layout that adapts to terminal width, handles overflow with scrolling, composes nested blocks, and reflows when the user resizes their window.
  • Progress indicators for tool execution, agent status, background tasks, cost tracking, and connection states — all updating concurrently.
  • State coordination across dozens of subsystems: MCP server connections, plugin status, teammate tasks, settings changes, model switching, permission contexts, and more.

The traditional approach to this is raw ANSI escape codes. You write \x1b[32m for green text, \x1b[1m for bold, \x1b[0m to reset, and manually track cursor positions to update specific regions of the screen. It works for simple tools. For something with the complexity of Claude Code — where a single screen might contain a streaming message, a permission dialog overlay, a status bar, a cost counter, and a spinner, all updating independently — it becomes unmaintainable within weeks.

The core issue is that raw escape codes give you no composition model. You cannot say “this region of the screen is owned by this component, and that component manages its own state and rendering.” Every piece of output code needs to know about every other piece.

How Claude Code Solves It

Architecture Flow

See the diagram above for a visual overview of this flow.

Try the Interactive SimulationFull View →

Claude Code uses React — the same React from web development — with a custom rendering target. Instead of the browser DOM, React components render to a terminal screen via a library called Ink, which Claude Code has forked and significantly extended.

The key insight is that React’s value was never about the DOM. React is a reconciliation engine: it takes a declarative description of what the UI should look like, diffs it against what the UI currently looks like, and applies the minimal set of changes. That abstraction works for any output target — browsers, mobile apps (React Native), PDFs, and yes, terminals.

The Rendering Pipeline

Rendering Pipeline
React Component TreeJSX + hooks
Ink Reconcilerreact-reconciler
Virtual DOM NodesDOMElement tree
Yoga Layout EngineFlexbox computation
Render Node to OutputStyled text segments
Screen Buffer2D cell grid
Screen DiffingCompare front/back frames
ANSI Escape CodesMinimal delta patch
Terminal Outputstdout write

Here is how the pipeline works. React components declare their UI using JSX — <Box>, <Text>, <Spinner> — just as web components use <div>, <span>, <input>. The Ink reconciler (built on react-reconciler) translates these into a virtual DOM of DOMElement nodes. Yoga — Facebook’s cross-platform flexbox engine — computes the layout for each node: x, y, width, height. The render-node-to-output pass converts the positioned nodes into styled text segments. These are written into a screen buffer (a 2D grid of cells). Finally, the renderer diffs the new screen buffer against the previous one and emits only the ANSI escape codes needed to update the changed cells.

Smart Pattern: That last step — screen diffing — is what makes the whole approach performant. Rather than clearing the terminal and rewriting everything on each render (which causes visible flicker), Claude Code’s renderer computes a minimal patch. A single character change in a message body produces a cursor move and a handful of bytes, not a full screen redraw.

The renderer.ts file in src/ink/ manages this double-buffering. It maintains front and back frames, validates Yoga layout dimensions, and handles edge cases like alt-screen mode (where the cursor is pinned at the top). The renderer also integrates with the Output class, which caches character tokenization and grapheme clustering across frames so that unchanged lines pay near-zero cost.

The Component Hierarchy

Claude Code’s REPL screen — the main interface you interact with — is itself a deeply nested React component tree:

Component Hierarchy
AppFpsMetricsProvider + StatsProvider + AppStateProvider
REPLMain interactive session
VirtualMessageList
AssistantTextMessage
AssistantToolUseMessage
UserTextMessage
UserBashOutputMessage
PromptInput
TextInput / VimTextInput
ContextSuggestions
QueuedCommands
PermissionRequest
BashPermissionRequest
FileEditPermissionRequest
SandboxPermissionRequest
StatusLine
Spinner

The REPL.tsx file in src/screens/ is arguably the most complex single component in the codebase. Its import list alone runs to 80+ lines, pulling in hooks for session management, cost tracking, telemetry, IDE integration, team/swarm coordination, remote sessions, search, history, and more. It is the convergence point where every subsystem meets the user.

The VirtualMessageList handles scrolling through potentially thousands of messages with virtualization — only rendering the messages visible in the current viewport. PromptInput manages the text input area with vi-mode support, typeahead suggestions, history recall, and paste handling. PermissionRequest renders the approval dialogs for tool use, with specialized sub-components for bash commands, file edits, filesystem access, MCP server connections, and sandbox operations.

The component library spans 144 entries in src/components/, covering everything from AgentProgressLine to WorktreeExitDialog.

State Management: 35 Lines That Do Everything

React components need state. Web apps reach for Redux, Zustand, MobX, or React’s own Context. Claude Code built its own store in 35 lines of code. I found this remarkably elegant:

export function createStore<T>(
  initialState: T,
  onChange?: OnChange<T>,
): Store<T> {
  let state = initialState
  const listeners = new Set<Listener>()

  return {
    getState: () => state,
    setState: (updater: (prev: T) => T) => {
      const prev = state
      const next = updater(prev)
      if (Object.is(next, prev)) return   // <-- deduplication
      state = next
      onChange?.({ newState: next, oldState: prev })
      for (const listener of listeners) listener()
    },
    subscribe: (listener: Listener) => {
      listeners.add(listener)
      return () => listeners.delete(listener)
    },
  }
}

This is the entire state management layer. No middleware, no dev tools, no action creators. Just a value, a set of listeners, and an update function. The critical detail is the Object.is(next, prev) check — if the updater returns the same reference, no listeners fire. This prevents render storms during high-frequency updates like token streaming.

State Flow
AppState StorecreateStore
getState()
useAppState(selector)useSyncExternalStore
ComponentRe-renders on change
Object.is Deduplication
setState(updater)
Same ref?
Yes: skip  |  No: notify listeners
Render:
Ink Re-render
Yoga Layout
Screen Buffer Diff
Terminal Update

The AppState type is a large immutable object containing everything the UI needs: settings, model selection, status line text, tool permission context, MCP server connections, plugin state, task states, teammate registries, and more. Components use useAppState with a selector function to subscribe to only the slice they need. Because useSyncExternalStore (React 18’s external store hook) powers the subscription, React guarantees tear-free reads.

The Custom Ink Fork

Claude Code does not use Ink as a dependency. It maintains a forked copy in src/ink/ — dozens of files covering the reconciler, layout engine integration, screen management, event system, focus management, animation frames, and terminal I/O.

Why fork? Because terminal UI has requirements that upstream Ink does not address:

  • Focus management (focus.ts): tracking which component has keyboard focus, moving focus between components (permission dialog appears over the message list, captures focus, then returns it).
  • Event system (events/): a full event dispatcher with keyboard events, mouse tracking, and hit testing — enabling click-to-select, hover detection, and mouse wheel scrolling.
  • Animation frames (hooks/use-animation-frame.ts): a requestAnimationFrame-equivalent for terminals.
  • Selection and scrolling (selection.ts): text selection with mouse, copy-on-select, URL detection, and scroll position management.
  • Alt-screen mode: full-screen terminal mode with cursor management.
  • Screen diffing optimization: double-buffered rendering with character-level diffing, style pooling, hyperlink pooling, and grapheme cache reuse across frames.

The fork contains 12 custom hooks in src/ink/hooks/, including use-input (keyboard handling), use-selection (text selection state), use-terminal-viewport (scroll position), use-search-highlight (search result overlay), and use-terminal-focus (window focus detection).

Component Library Organization

Component Library — src/components/ (144 entries)
messages/ (34 types)
AssistantTextMessage
AssistantToolUseMessage
UserTextMessage
UserBashOutputMessage
RateLimitMessage
SystemAPIErrorMessage
… 28 more
permissions/ (20+)
BashPermissionRequest
FileEditPermissionRequest
SandboxPermissionRequest
SkillPermissionRequest
… 16 more
diff/
Diff views
mcp/
MCP server UI
skills/
Skill components
teams/
Teammate UI
design-system/
Primitives
ui/
OrderedList, TreeSelect
hooks/
Hook config UI
Top-level
App, PromptInput, Spinner, StatusLine, MessageRow, etc.

What Works Well

Web-dev familiarity. Any React developer can read Claude Code’s UI code. The patterns are identical: functional components, hooks for state and side effects, props for data flow, context for cross-cutting concerns. The learning curve to contribute is days, not weeks.

Hooks enable clean separation. The codebase contains 100+ hooks in src/hooks/ plus a dozen in src/ink/hooks/, covering everything from useElapsedTime to useSwarmPermissionPoller. Each hook encapsulates a single concern — cost tracking, IDE integration, clipboard handling, vim input mode — and can be composed into any component that needs it.

Smart Pattern: The store is trivially debuggable. At roughly three dozen lines, the state store has zero magic. There is no middleware stack to trace through, no action type strings to grep for, no selector memoization bugs to hunt. When a component renders unexpectedly, you add a log to setState and see exactly what changed.

Object.is deduplication prevents render storms. During token streaming, the store might be updated dozens of times per second. But only the components subscribed to the changing slice re-render. The cost counter, status line, permission dialogs, and everything else stay dormant.

Screen diffing makes 60fps feasible. Terminal rendering is I/O-bound: writing bytes to stdout is the bottleneck. By diffing screen buffers and emitting only changed cells, Claude Code avoids the “clear and redraw” pattern that causes visible flicker. The Output class caches tokenization across frames, so steady-state renders are nearly free.

What Could Be Better

⚠️ Watch Out: There are no barrel exports or component index. With 144 entries in src/components/, discovering what exists requires browsing the directory. There is no index.ts that re-exports everything, no component catalog, and no documentation of which component to use for what purpose.

The custom Ink fork creates maintenance burden. Upstream Ink receives bug fixes and improvements that must be manually evaluated and backported. The fork has diverged significantly enough that merging upstream changes is non-trivial.

No component documentation or visual catalog. Web React ecosystems have Storybook. No equivalent exists for terminal components. You cannot see what CostThresholdDialog looks like without running the full application and triggering the cost threshold condition.

The REPL screen is a monolith. With 100+ imports and over 5,000 lines in src/screens/REPL.tsx, it has become a convergence point that is difficult to modify without risk of side effects. Extracting sub-concerns into smaller screen-level components would improve maintainability.

The Takeaway

Component models work everywhere — even terminals. The key is not the rendering target but the composition model: the ability to break a complex interface into independent pieces that own their state, declare their layout, and compose predictably. React provides that model. Ink translates it to ANSI. A tiny store keeps the state flow simple. Screen diffing keeps the output fast.

If you are building a CLI tool that has grown beyond simple console.log output — if you find yourself tracking cursor positions, managing overlapping content regions, or fighting flickering updates — consider whether a component model would help. It does not have to be React. The principle is what matters: declarative UI descriptions, diffed against reality, producing minimal output updates.

Claude Code’s terminal UI is proof that the boundary between “web app” and “CLI tool” is thinner than most people assume. The same engineers, the same patterns, the same mental model — just a different output device.


This is Part 8 of the “Inside Claude Code” series.

← Part 7: Remembering What Matters | Part 9: When One Agent Isn’t Enough →