Most React apps reach for a library — Redux, Zustand, Jotai — when their state
needs to span the component tree. Claude Code builds its own store from scratch in
35 lines of TypeScript and plugs it directly into React's built-in
useSyncExternalStore hook. Understanding why, and what sits on top, is
essential for navigating any non-trivial change to the REPL.
state/store.ts → state/AppStateStore.ts →
state/AppState.tsx → state/onChangeAppState.ts →
state/selectors.ts → state/teammateViewHelpers.ts
The architecture has three distinct layers:
createStore<T>
Generic, framework-free store. 35 lines. Knows nothing about React or AppState.
AppState + AppStateStore
The full state shape (400+ fields) and its factory. Separated from React so .ts callers don't pull in React.
AppStateProvider + hooks
Provider that wires the store into a Context. useAppState, useSetAppState, useAppStateStore.
onChangeAppState
Single diff-observer passed as onChange to createStore. Fires on every state transition.
selectors.ts
Pure functions over AppState slices — no side effects, no store access.
teammateViewHelpers.ts
Stateful updaters for teammate-view transitions — colocated with the feature they serve.
The entire store primitive is in state/store.ts. Three concepts,
35 lines:
type Listener = () => void
type OnChange<T> = (args: { newState: T; oldState: T }) => void
export type Store<T> = {
getState: () => T
setState: (updater: (prev: T) => T) => void
subscribe: (listener: Listener) => () => void
}
export function createStore<T>(
initialState: T,
onChange?: OnChange<T>,
): Store<T> {
let state = initialState
const listeners = new Set<Listener>()
return {
getState: () => state,
setState: (updater) => {
const prev = state
const next = updater(prev)
if (Object.is(next, prev)) return // bail if no change
state = next
onChange?.({ newState: next, oldState: prev }) // side-effect hook
for (const listener of listeners) listener() // notify React
},
subscribe: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener) // unsubscribe
},
}
}
Why not useState / useReducer?
React's own hooks tie state lifetime to a component tree. Claude Code needs state
that is readable from non-React code: headless mode, the SDK print layer,
per-process teammate sessions, the onChangeAppState side-effect chain.
A plain JS object with a Set of listeners costs nothing to pass around
outside React.
Why not Zustand / Jotai?
Zero additional bundle dependency. The interface Claude Code actually needs —
getState, setState, subscribe — maps exactly
onto what useSyncExternalStore requires. There is nothing left to buy.
setState takes an updater function (prev) => next,
never a partial object. This enforces immutability at the call site: callers must spread
the previous state and return a new reference. Object.is equality check means
re-renders only fire when the reference actually changes.
Deep dive: how useSyncExternalStore fits
React 18 introduced useSyncExternalStore precisely for stores like this one.
It takes three arguments: subscribe, getSnapshot, and an optional
getServerSnapshot. The contract:
- subscribe — register a callback, return an unsubscribe function. Matches
store.subscribeexactly. - getSnapshot — return the current value synchronously. This is the selector-wrapped
store.getState().
React calls getSnapshot during render to read the current value, and calls
the subscribed callback whenever the store updates — triggering a re-render only if
the snapshot changed. This gives tearing-free updates without any React-internal scheduler
cooperation required from the store.
// From AppState.tsx — the full useAppState implementation:
export function useAppState(selector) {
const store = useAppStore()
const get = () => selector(store.getState())
return useSyncExternalStore(store.subscribe, get, get)
}
The get closure is recreated on every render if selector
changes identity — which is why the compiled output (React Compiler's _c
memo cache) caches it by [selector, store]. Pass an inline arrow as
selector and you'll defeat the cache on every render.
AppState is defined in state/AppStateStore.ts. It is enormous
— over 90 distinct fields — but it has clear internal structure. The type is
DeepImmutable<{...}> for the serializable portion, with a small number
of fields that escape the immutability wrapper (function types, Map,
Set) explicitly listed after the &.
tasks, agentNameRegistry, sessionHooks,
activeOverlays, and replContext are not wrapped in
DeepImmutable because they contain function types that TypeScript's
recursive readonly transform doesn't handle. Treat them as logically immutable
(always spread before mutating) even though the type permits mutation.
The fields break down into six logical categories:
Model & Settings
- settings, verbose
- mainLoopModel, mainLoopModelForSession
- thinkingEnabled, effortValue, fastMode
- kairosEnabled, agent, authVersion
View & Navigation
- expandedView, isBriefOnly
- footerSelection, spinnerTip
- activeOverlays, statusLineText
- viewSelectionMode, coordinatorTaskIndex
Tool & Denial
- toolPermissionContext (mode, bypass flags)
- denialTracking
- initialMessage (mode override)
- pendingPlanVerification
Concurrency
- tasks (keyed by taskId)
- agentNameRegistry (name → AgentId)
- foregroundedTaskId, viewingAgentTaskId
- teamContext, standaloneAgentContext
Connectivity
- remoteSessionUrl, remoteConnectionStatus
- replBridgeEnabled/Connected/Active/...
- ultraplanSessionUrl, isUltraplanMode
- workerSandboxPermissions
Features
- mcp (clients, tools, commands, resources)
- plugins (enabled, disabled, installationStatus)
- speculation, promptSuggestion
- notifications, elicitation, todos, inbox
- tungstenActive* (tmux panel), bagel* (browser)
- computerUseMcpState, replContext, fileHistory
Selected field reference
| Field | Type | Purpose |
|---|---|---|
| settings | SettingsJson | Full settings.json contents — read by nearly every subsystem |
| mainLoopModel | ModelSetting | Active model override; null = use default. Written to settings on change via onChangeAppState |
| toolPermissionContext | ToolPermissionContext | Current permission mode (default/plan/auto/yolo) plus bypass availability flags |
| tasks | { [taskId]: TaskState } | Live state for all in-flight agent tasks (local_agent, in_process_teammate, etc.) |
| agentNameRegistry | Map<string, AgentId> | Name → AgentId routing table populated by Agent tool; latest wins on collision |
| speculation | SpeculationState | Idle or active predictive completion with boundary, abort, and pipelined suggestion state |
| expandedView | 'none' | 'tasks' | 'teammates' | Controls which panel is expanded; persisted to globalConfig via onChangeAppState |
| notifications | { current, queue } | Priority-queued notification system; useNotifications manages transitions |
| replBridgeEnabled | boolean | Desired state of always-on bridge (controlled by /config or footer toggle) |
| mcp.pluginReconnectKey | number | Monotonically incremented by /reload-plugins; effects watch this as a dependency trigger |
| initialMessage | { message, mode, ... } | null | Set to trigger a REPL query programmatically (CLI args, plan mode exit) |
| activeOverlays | ReadonlySet<string> | Registry of open Select dialogs — Escape key checks this before acting |
| fileHistory | FileHistoryState | Snapshots + tracked files for undo/rewind support |
| attribution | AttributionState | Commit authorship tracking for git operations |
| tungstenActiveSession | { sessionName, socketName, target } | undef | Active tmux integration session (ant-only) |
| computerUseMcpState | { allowedApps, grantFlags, ... } | undef | Per-session computer-use allowlist and display state (chicago MCP) |
Deep dive: DeepImmutable and the & escape hatch
The AppState type declaration is structured as:
export type AppState = DeepImmutable<{
settings: SettingsJson
verbose: boolean
// ... ~60 serializable fields ...
}> & {
// Excluded from DeepImmutable — contain function types
tasks: { [taskId: string]: TaskState }
agentNameRegistry: Map<string, AgentId>
mcp: { clients: MCPServerConnection[]; /* ... */ }
// ...
}
DeepImmutable is a recursive conditional type that wraps every nested
object in Readonly<>. The intersection (&) appends
the mutable-typed fields back without the immutability wrapper. The TypeScript compiler
accepts this because intersection merges the property sets: the Readonly
version of each field from the first half is effectively overridden by the raw type
from the second half when the field names collide — but the intent is to list them
separately so they're structurally visible.
In practice, nothing stops a caller from mutating tasks[id].someField = x
at the JS runtime level. The discipline comes from team convention and the updater
pattern in setState.
Deep dive: SpeculationState — the most complex field
speculation is a discriminated union with two arms:
type SpeculationState =
| { status: 'idle' }
| {
status: 'active'
id: string
abort: () => void
startTime: number
messagesRef: { current: Message[] } // mutable ref — no array copy per msg
writtenPathsRef: { current: Set<string> } // relative paths in overlay
boundary: CompletionBoundary | null
suggestionLength: number
toolUseCount: number
isPipelined: boolean
contextRef: { current: REPLHookContext }
pipelinedSuggestion?: { text: string; promptId: ...; generationRequestId: ... } | null
}
The messagesRef and writtenPathsRef fields are
intentionally mutable — they escape the immutability contract so that
speculation can append messages to an in-progress prediction without triggering a
full store update (and a re-render) for every token. The ref is mutated directly;
only state transitions (idle ↔ active) go through setState.
The second argument to createStore is an optional
onChange callback. Claude Code passes
onChangeAppState here — a single function that fires on
every state transition and diffs relevant fields to drive side effects.
setState call that changes the mode automatically syncs — with zero changes
to individual call sites.
The current diff blocks in onChangeAppState:
export function onChangeAppState({ newState, oldState }) {
// 1. Permission mode — sync to CCR external_metadata + SDK status stream
const prevMode = oldState.toolPermissionContext.mode
const newMode = newState.toolPermissionContext.mode
if (prevMode !== newMode) {
const prevExternal = toExternalPermissionMode(prevMode)
const newExternal = toExternalPermissionMode(newMode)
if (prevExternal !== newExternal) {
// Guard: internal-only modes (bubble, ungated-auto) don't pollute CCR.
// is_ultraplan_mode set null per RFC 7396 (removes key from JSON patch).
notifySessionMetadataChanged({ permission_mode: newExternal, ... })
}
notifyPermissionModeChanged(newMode) // SDK channel — passes raw mode
}
// 2. mainLoopModel — persist to settings + bootstrap override
if (newState.mainLoopModel !== oldState.mainLoopModel) {
if (newState.mainLoopModel === null) {
updateSettingsForSource('userSettings', { model: undefined })
setMainLoopModelOverride(null)
} else {
updateSettingsForSource('userSettings', { model: newState.mainLoopModel })
setMainLoopModelOverride(newState.mainLoopModel)
}
}
// 3. expandedView — persist to globalConfig (showExpandedTodos / showSpinnerTree)
if (newState.expandedView !== oldState.expandedView) {
saveGlobalConfig(current => ({
...current,
showExpandedTodos: newState.expandedView === 'tasks',
showSpinnerTree: newState.expandedView === 'teammates',
}))
}
// 4. verbose — persist to globalConfig
if (newState.verbose !== oldState.verbose) {
saveGlobalConfig(current => ({ ...current, verbose: newState.verbose }))
}
// 5. tungstenPanelVisible — ant-only, persist to globalConfig
if (process.env.USER_TYPE === 'ant' && newState.tungstenPanelVisible !== oldState.tungstenPanelVisible) {
saveGlobalConfig(current => ({ ...current, tungstenPanelVisible: newState.tungstenPanelVisible }))
}
// 6. settings — clear auth caches + re-apply env vars
if (newState.settings !== oldState.settings) {
clearApiKeyHelperCache()
clearAwsCredentialsCache()
clearGcpCredentialsCache()
if (newState.settings.env !== oldState.settings.env) {
applyConfigEnvironmentVariables()
}
}
}
The externalization guard
Not all internal permission modes have external equivalents. The
toExternalPermissionMode call collapses internal-only names like
'bubble' and 'ungated-auto' to 'default'
before sending to CCR. Without this, the remote dashboard would receive meaningless
mode names and potentially cycle on internal transitions
(default → bubble → default) that are invisible from the outside.
Deep dive: externalMetadataToAppState — the inverse
The file also exports externalMetadataToAppState, which is the
inverse of the permission-mode push: when a worker process restarts
and pulls SessionExternalMetadata from the CCR session store, it
calls this to hydrate AppState with the persisted mode.
export function externalMetadataToAppState(
metadata: SessionExternalMetadata
): (prev: AppState) => AppState {
return prev => ({
...prev,
...(typeof metadata.permission_mode === 'string'
? { toolPermissionContext: {
...prev.toolPermissionContext,
mode: permissionModeFromString(metadata.permission_mode),
}}
: {}),
...(typeof metadata.is_ultraplan_mode === 'boolean'
? { isUltraplanMode: metadata.is_ultraplan_mode }
: {}),
})
}
Notice it returns an updater function (prev) => AppState — it's
designed to be passed directly to store.setState(). This is the
conventional shape for all AppState mutations.
state/AppState.tsx is the React face of the store. It exports three hooks
and one provider. The file is compiled React (with _c from the React
Compiler), so the raw source reads a little odd — but the semantics are clean.
// Read a slice — re-renders only when the selected value changes
const verbose = useAppState(s => s.verbose)
const model = useAppState(s => s.mainLoopModel)
// Write without subscribing — stable reference, never causes re-renders
const setAppState = useSetAppState()
setAppState(prev => ({ ...prev, verbose: true }))
// Get the raw store — for passing to non-React helpers
const store = useAppStateStore()
doSomethingOutsideReact(store.getState, store.setState)
useSyncExternalStore
compares snapshots with Object.is. An inline
s => ({ a: s.a, b: s.b }) creates a new object on every render, which
triggers an infinite re-render loop. Return a single sub-object reference or a
primitive:
// Good — returns existing reference
const { text, promptId } = useAppState(s => s.promptSuggestion)
// Bad — new object every render
const { text, promptId } = useAppState(s => ({ text: s.promptSuggestion.text, promptId: s.promptSuggestion.promptId }))
AppStateProvider setup
AppStateProvider creates the store exactly once (via useState
lazy initializer), wires onChangeAppState into it, then applies a
one-time fixup if remote settings were loaded before the component mounted:
const [store] = useState(() =>
createStore(
initialState ?? getDefaultAppState(),
onChangeAppState, // side-effect hook wired here
)
)
// One-time remote-settings fixup on mount
useEffect(() => {
const { toolPermissionContext } = store.getState()
if (toolPermissionContext.isBypassPermissionsModeAvailable
&& isBypassPermissionsModeDisabled()) {
store.setState(prev => ({
...prev,
toolPermissionContext: createDisabledBypassPermissionsContext(prev.toolPermissionContext)
}))
}
}, [])
selectors.ts — pure derivations
state/selectors.ts contains functions that derive computed values from
AppState slices without accessing the store or producing side effects.
They accept a Pick<AppState, ...> (not the full state) so callers
can test them in isolation.
/**
* Get the currently viewed teammate task, if any.
* Takes a Pick — not the full AppState — so tests don't need the whole object.
*/
export function getViewedTeammateTask(
appState: Pick<AppState, 'viewingAgentTaskId' | 'tasks'>
): InProcessTeammateTaskState | undefined { ... }
/**
* Discriminated union — tells input routing exactly where to send a message.
*/
export type ActiveAgentForInput =
| { type: 'leader' }
| { type: 'viewed'; task: InProcessTeammateTaskState }
| { type: 'named_agent'; task: LocalAgentTaskState }
export function getActiveAgentForInput(appState: AppState): ActiveAgentForInput { ... }
teammateViewHelpers.ts — colocated state transitions
More complex state transitions for the teammate-view feature live in
state/teammateViewHelpers.ts. These are not React hooks — they take
setAppState as an argument, making them testable and usable from any context.
// Enter a teammate's transcript view — retain: true blocks eviction, loads from disk
export function enterTeammateView(
taskId: string,
setAppState: (updater: (prev: AppState) => AppState) => void,
): void
// Exit back to leader's view — releases retain, schedules eviction if terminal
export function exitTeammateView(
setAppState: (updater: (prev: AppState) => AppState) => void,
): void
// Context-sensitive x button: abort if running, dismiss if terminal
export function stopOrDismissAgent(
taskId: string,
setAppState: (updater: (prev: AppState) => AppState) => void,
): void
release(task) helper inside teammateViewHelpers.ts is a
good example of local helper hygiene: it's not exported (no one outside the file needs
it), it's pure (takes a task, returns a task), and it encodes a policy decision in one
place — "releasing" a task means retain: false, messages: undefined,
and setting evictAfter if the task is in a terminal state.
Deep dive: retain/evict lifecycle for agent tasks
Agent task rows in the background panel follow a retain/evict lifecycle:
- Stub form:
retain: false,messages: undefined. Row shows in panel but no transcript is loaded. - Retained form:
retain: true, messages loaded. Triggered byenterTeammateView. Blocks eviction and enables streaming. - Eviction pending: task is terminal,
evictAfter = Date.now() + 30_000. Row lingers for 30s (PANEL_GRACE_MS) so the user sees it complete, then the filter drops it. - Immediate dismiss:
evictAfter = 0. Filter hides row immediately. Triggered by the x button on a terminal task.
The PANEL_GRACE_MS = 30_000 constant is inlined in both
framework.ts and teammateViewHelpers.ts with a comment
instructing them to be kept in sync — a deliberate choice to avoid importing
across a module boundary that would create a circular dependency through
BackgroundTasksDialog.
How a state update moves from a component through the system:
Not everything in context/ is React Context in the traditional sense.
The directory contains a mix of patterns:
modalContext, overlayContext, promptOverlayContext
True React Context — values shared down the component tree, not stored in AppState.
notifications.tsx
Reads/writes AppState.notifications via useAppState + useSetAppState. No local state.
fpsMetrics, stats
Own their data outside AppState (perf metrics don't need to be part of the main diff). May use their own store or module-level state.
mailbox, voice, QueuedMessage
Manage WebSocket or IPC connections. May read AppState but primarily drive effects.
context.ts (not context/) is entirely unrelated to
React Context. It builds the system prompt injected into each Claude API call:
getSystemContext() (git status, cache breaker) and
getUserContext() (CLAUDE.md files, current date). Both are
memoize()d for the lifetime of the conversation.
Key Takeaways
createStore<T>is 35 lines of TypeScript that implements exactly the interfaceuseSyncExternalStoreneeds — no library required.setStatetakes an updater function, not a partial. Callers always spread the previous state. TheObject.isbail-out prevents spurious re-renders.AppStateis enormous on purpose — it is the single source of truth for the entire session. The alternative (scattered module singletons) would be harder to test and reset.- The
AppStatetype usesDeepImmutable<...> & { mutables }to make the serializable core read-only while keeping function-typed fields usable. onChangeAppStateis the architectural key: centralizing all side effects of state changes in a single diff observer means new mutation paths automatically get the right behavior for free.- Selectors take a
Pick<AppState, ...>not the full state — making them testable without constructing a complete default state. - Transition helpers like
enterTeammateViewreceivesetAppStateas an argument — keeping them framework-agnostic and testable outside React. - The retain/evict lifecycle for agent task rows is entirely managed through AppState fields (
retain,evictAfter) rather than component-local state.
Quiz
store.setState do when the updater returns the exact same reference it received?AppStateStore.ts live in a .ts file instead of a .tsx file?useAppState(s => ({ a: s.a, b: s.b }))?onChangeAppState was introduced, what was the primary problem with permission-mode syncing?onChangeAppState when the permission mode changes from 'default' to 'bubble' (an internal-only mode)?teammateViewHelpers.ts inline PANEL_GRACE_MS = 30_000 instead of importing it from framework.ts?