markdown.engineering
Lesson 12

State Management

From a 35-line custom store to a 400-field AppState — how Claude Code keeps every component in sync without Redux, Zustand, or Context thrash.

01 Overview

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.

Source files covered
state/store.tsstate/AppStateStore.tsstate/AppState.tsxstate/onChangeAppState.tsstate/selectors.tsstate/teammateViewHelpers.ts

The architecture has three distinct layers:

Layer 1 — Primitive

createStore<T>

Generic, framework-free store. 35 lines. Knows nothing about React or AppState.

Layer 2 — Domain

AppState + AppStateStore

The full state shape (400+ fields) and its factory. Separated from React so .ts callers don't pull in React.

Layer 3 — React

AppStateProvider + hooks

Provider that wires the store into a Context. useAppState, useSetAppState, useAppStateStore.

Side-Effect

onChangeAppState

Single diff-observer passed as onChange to createStore. Fires on every state transition.

Derived State

selectors.ts

Pure functions over AppState slices — no side effects, no store access.

Transition Logic

teammateViewHelpers.ts

Stateful updaters for teammate-view transitions — colocated with the feature they serve.

02 The createStore Pattern

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.

Key invariant
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.subscribe exactly.
  • 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.

03 The AppState Shape

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 &.

Escape hatch
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:

Session Core

Model & Settings

  • settings, verbose
  • mainLoopModel, mainLoopModelForSession
  • thinkingEnabled, effortValue, fastMode
  • kairosEnabled, agent, authVersion
UI State

View & Navigation

  • expandedView, isBriefOnly
  • footerSelection, spinnerTip
  • activeOverlays, statusLineText
  • viewSelectionMode, coordinatorTaskIndex
Permissions

Tool & Denial

  • toolPermissionContext (mode, bypass flags)
  • denialTracking
  • initialMessage (mode override)
  • pendingPlanVerification
Agent & Tasks

Concurrency

  • tasks (keyed by taskId)
  • agentNameRegistry (name → AgentId)
  • foregroundedTaskId, viewingAgentTaskId
  • teamContext, standaloneAgentContext
Remote & Bridge

Connectivity

  • remoteSessionUrl, remoteConnectionStatus
  • replBridgeEnabled/Connected/Active/...
  • ultraplanSessionUrl, isUltraplanMode
  • workerSandboxPermissions
Subsystem State

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

FieldTypePurpose
settingsSettingsJsonFull settings.json contents — read by nearly every subsystem
mainLoopModelModelSettingActive model override; null = use default. Written to settings on change via onChangeAppState
toolPermissionContextToolPermissionContextCurrent 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.)
agentNameRegistryMap<string, AgentId>Name → AgentId routing table populated by Agent tool; latest wins on collision
speculationSpeculationStateIdle 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
replBridgeEnabledbooleanDesired state of always-on bridge (controlled by /config or footer toggle)
mcp.pluginReconnectKeynumberMonotonically incremented by /reload-plugins; effects watch this as a dependency trigger
initialMessage{ message, mode, ... } | nullSet to trigger a REPL query programmatically (CLI args, plan mode exit)
activeOverlaysReadonlySet<string>Registry of open Select dialogs — Escape key checks this before acting
fileHistoryFileHistoryStateSnapshots + tracked files for undo/rewind support
attributionAttributionStateCommit authorship tracking for git operations
tungstenActiveSession{ sessionName, socketName, target } | undefActive tmux integration session (ant-only)
computerUseMcpState{ allowedApps, grantFlags, ... } | undefPer-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.

04 onChangeAppState — the Side-Effect Chokepoint

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.

Why this matters
Before this pattern was introduced, permission-mode changes were relayed to the remote dashboard (CCR) by only 2 of 8+ mutation paths. The other 6 paths mutated AppState silently, leaving the web UI stale. Centralizing the diff here means any 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.

05 The React Hooks Layer

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)
Selector rule
Do NOT return new objects or arrays from the selector. 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)
    }))
  }
}, [])
06 Selectors and Transition Helpers

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
Design pattern
The 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 by enterTeammateView. 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.

07 Full Data Flow Diagram

How a state update moves from a component through the system:

flowchart TD A["Component calls\nuseSetAppState()"] -->|"returns store.setState"| B["store.setState(updater)"] B --> C{"Object.is(next, prev)?"} C -->|"same"| Z["Return — no-op"] C -->|"changed"| D["state = next"] D --> E["onChangeAppState({ newState, oldState })"] E --> F1["permission mode changed?"] E --> F2["mainLoopModel changed?"] E --> F3["expandedView changed?"] E --> F4["settings changed?"] F1 -->|"yes"| G1["notifySessionMetadataChanged\nnotifyPermissionModeChanged"] F2 -->|"yes"| G2["updateSettingsForSource\nsetMainLoopModelOverride"] F3 -->|"yes"| G3["saveGlobalConfig"] F4 -->|"yes"| G4["clearAuthCaches\napplyConfigEnvironmentVariables"] D --> H["for listener of listeners: listener()"] H --> I["useSyncExternalStore\ntriggers re-render"] I --> J["selector(store.getState())\ncompares with Object.is"] J -->|"changed"| K["Component re-renders"] J -->|"same"| L["Render skipped"]
08 Context vs State: Where Context Lives

Not everything in context/ is React Context in the traditional sense. The directory contains a mix of patterns:

React Context (thin)

modalContext, overlayContext, promptOverlayContext

True React Context — values shared down the component tree, not stored in AppState.

Hooks over AppState

notifications.tsx

Reads/writes AppState.notifications via useAppState + useSetAppState. No local state.

External Store Pattern

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.

Side-Effect Manager

mailbox, voice, QueuedMessage

Manage WebSocket or IPC connections. May read AppState but primarily drive effects.

context.ts is different
The top-level 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 interface useSyncExternalStore needs — no library required.
  • setState takes an updater function, not a partial. Callers always spread the previous state. The Object.is bail-out prevents spurious re-renders.
  • AppState is 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 AppState type uses DeepImmutable<...> & { mutables } to make the serializable core read-only while keeping function-typed fields usable.
  • onChangeAppState is 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 enterTeammateView receive setAppState as 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

1. What does store.setState do when the updater returns the exact same reference it received?
2. Why does AppStateStore.ts live in a .ts file instead of a .tsx file?
3. What is the danger of writing useAppState(s => ({ a: s.a, b: s.b }))?
4. Before onChangeAppState was introduced, what was the primary problem with permission-mode syncing?
5. What happens in onChangeAppState when the permission mode changes from 'default' to 'bubble' (an internal-only mode)?
6. Why does teammateViewHelpers.ts inline PANEL_GRACE_MS = 30_000 instead of importing it from framework.ts?
0/6