How AppState, React context, selectors, and change observers work alongside the React + Ink terminal UI layer, its ~140 components, ~85 hooks, and the design system.
Claude Code’s UI and state are built entirely with React patterns — just targeting the terminal instead of a browser. State flows from a single mutable AppState object through React context into ~140 components rendered by Ink.
AppState is the global mutable state object, defined in src/state/AppStateStore.ts. It is typed as DeepImmutable (except for function-typed fields like tasks) and covers everything the application needs at runtime:
// src/state/AppStateStore.tsexport type AppState = DeepImmutable<{ settings: SettingsJson verbose: boolean mainLoopModel: ModelSetting mainLoopModelForSession: ModelSetting statusLineText: string | undefined toolPermissionContext: ToolPermissionContext expandedView: 'none' | 'tasks' | 'teammates' // IDE bridge state replBridgeEnabled: boolean replBridgeConnected: boolean replBridgeSessionActive: boolean replBridgeSessionUrl: string | undefined // Remote session state remoteConnectionStatus: 'connecting' | 'connected' | 'reconnecting' | 'disconnected' remoteBackgroundTaskCount: number // MCP state mcp: { clients: MCPServerConnection[] tools: Tool[] commands: Command[] resources: Record<string, ServerResource[]> } // ... and many more fields}> & { tasks: { [taskId: string]: TaskState } agentNameRegistry: Map<string, AgentId>}
The AppState object is passed into tool contexts, giving tools access to conversation history, settings, and runtime state without prop-drilling.
createStore() in src/state/store.ts creates a lightweight observable store. State updates flow as setAppState(f: (prev: AppState) => AppState) — an immutable update function passed to both the REPL and the Query Engine:
// Consumers update state via the settersetAppState(prev => ({ ...prev, verbose: true,}))
src/state/onChangeAppState.ts registers side-effects that fire when specific AppState fields change — for example, persisting settings to disk, triggering analytics events, or updating external services when the model changes.
The entire terminal UI is built with React + Ink. Ink provides terminal-native primitives (Box, Text, useInput()) that map to React components, so all standard React patterns — hooks, context, concurrent rendering — work as expected.
Components are styled with Chalk for terminal colors
React Compiler is enabled for optimized re-renders
Config format changes between versions are handled by migration modules. Each migration reads the old format and transforms it to the current schema. Examples from src/main.tsx:
import { migrateAutoUpdatesToSettings } from './migrations/migrateAutoUpdatesToSettings.js'import { migrateBypassPermissionsAcceptedToSettings } from './migrations/migrateBypassPermissionsAcceptedToSettings.js'import { migrateFennecToOpus } from './migrations/migrateFennecToOpus.js'import { migrateLegacyOpusToCurrent } from './migrations/migrateLegacyOpusToCurrent.js'import { migrateSonnet45ToSonnet46 } from './migrations/migrateSonnet45ToSonnet46.js'
Migrations run at startup before the REPL mounts, ensuring the config is always in the current schema before any component reads it.
React Compiler is enabled for automatic memoization of components and hooks. This eliminates most manual useMemo / useCallback calls and ensures the terminal UI re-renders only the subtrees affected by a state change — important when rendering large conversation histories with 100+ messages.
Because React Compiler handles memoization automatically, components in src/components/ generally avoid manual memo(), useMemo, and useCallback wrappers unless there is a specific reason.