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
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
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:
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.
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): arequestAnimationFrame-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
AssistantToolUseMessage
UserTextMessage
UserBashOutputMessage
RateLimitMessage
SystemAPIErrorMessage
… 28 more
FileEditPermissionRequest
SandboxPermissionRequest
SkillPermissionRequest
… 16 more
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
setStateand 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 noindex.tsthat 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 →