Skip to content

Chapter 04: State Management

What You'll Learn

By the end of this chapter, you will be able to:

  • Explain the two-tier state architecture and articulate the precise responsibility boundary between src/bootstrap/state.ts and the AppState store
  • Read src/state/store.ts and explain every design choice in its 35 lines: why Object.is is the equality check, why onChange fires before listeners, and what the returned unsubscribe function is for
  • Understand how AppStateProvider in src/state/AppState.tsx connects the custom store to React's concurrent-mode reconciler via useSyncExternalStore
  • Explain why the tasks field, agentNameRegistry, and mcp sub-object are excluded from DeepImmutable<> in AppStateStore.ts
  • Write a new AppState field, update it from a tool, and read it in a React component — following the correct patterns at every step
  • Explain what onChangeAppState in src/state/onChangeAppState.ts is for, why it exists, and what bug it fixed
  • Use src/state/selectors.ts to derive computed state without introducing side effects

The Problem: Two Kinds of State

Claude Code runs as an interactive terminal process. At any moment it holds state that belongs to fundamentally different lifetimes and audiences.

Some state exists for the life of the OS process: the session ID that was stamped at startup, the accumulated cost in USD across all API calls, the OpenTelemetry meter handles, the path to the project root. None of these values change in response to user actions. Nothing in the UI needs to re-render when they change. They are process-level infrastructure.

Other state exists specifically to drive the React UI: whether the expanded task view is open, which permission mode the session is in, the list of active MCP connections, the current notification to display. These values change constantly, every change must trigger a React re-render, and they become meaningless once the React tree is torn down.

Mixing both kinds of state into a single store would require the entire React tree to subscribe to infrastructure mutations that never affect the display. Conversely, putting UI state into a plain module-level object would require manually notifying every component on every change.

Claude Code solves this by maintaining two completely separate state tiers.


The Two-Tier Architecture

The diagram below maps the two state tiers, how they are accessed, and how they relate to the React component tree:

The left side never notifies anyone. The right side notifies React on every mutation.


src/state/store.ts: Thirty-Five Lines That Drive React

The entire store implementation is 35 lines. It is worth reading every one of them with care.

typescript
// src/state/store.ts:1-8
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
}

The public interface is minimal. getState returns the current snapshot. setState takes an updater function — a pure function of the previous state that returns the next state. subscribe registers a listener and returns an unsubscribe function.

The updater-function pattern for setState is a deliberate choice. It rules out race conditions where two callers both read the current state, both derive a new state independently, and the second write overwrites the first. An updater always sees the most recent state, so concurrent calls produce deterministic results.

typescript
// src/state/store.ts:10-34
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    // Skip if reference-equal
      state = next
      onChange?.({ newState: next, oldState: prev })
      for (const listener of listeners) listener()
    },

    subscribe: (listener: Listener) => {
      listeners.add(listener)
      return () => listeners.delete(listener)   // Unsubscribe function
    },
  }
}

Several implementation choices deserve explicit attention.

Object.is for equality. The setState method bails out immediately if Object.is(next, prev) is true. This is reference equality: if the updater function returns the exact same object it received, no notification fires. This makes the contract explicit: callers must produce a new object for a change to register. Conveniently, returning prev unchanged (as in prev => prev) is always cheap and always safe — it is the correct idiom for a no-op update.

onChange fires before listeners. The side-effect callback onChange?.({ newState: next, oldState: prev }) is invoked before the listener loop. This means that when React re-renders in response to a Listener call, any side effects that onChange initiated (such as persisting a value to disk or notifying an external SDK) have already been dispatched. The ordering prevents a class of bugs where the UI renders new state that the external world has not yet been told about.

Set<Listener> instead of an array. Using Set means that the same listener function can only be registered once. Duplicate subscribe calls from component re-renders do not accumulate phantom listeners. When subscribe returns its teardown function () => listeners.delete(listener), React's useEffect cleanup will remove exactly the listener that was registered — even if subscribe was called multiple times.

The returned unsubscribe function. subscribe returns () => listeners.delete(listener). This is the standard subscription teardown contract expected by useSyncExternalStore. React calls the teardown when a component unmounts or when the store reference changes.


src/state/AppStateStore.ts: The 150+ Field State Tree

AppState is the type that parameterizes the store. Its definition spans the full complexity of an interactive Claude Code session.

The DeepImmutable split

typescript
// src/state/AppStateStore.ts:89-95
export type AppState = DeepImmutable<{
  settings: SettingsJson
  verbose: boolean
  mainLoopModel: ModelSetting
  mainLoopModelForSession: ModelSetting
  statusLineText: string | undefined
  expandedView: 'none' | 'tasks' | 'teammates'
  // ... many more fields
}> & {
  // These fields are excluded from DeepImmutable:
  tasks: { [taskId: string]: TaskState }
  agentNameRegistry: Map<string, AgentId>
  foregroundedTaskId?: string
  viewingAgentTaskId?: string
  mcp: { clients: MCPServerConnection[]; tools: Tool[]; commands: Command[]; ... }
  plugins: { enabled: LoadedPlugin[]; disabled: LoadedPlugin[]; ... }
  // ...
}

The type is a TypeScript intersection of two parts. The first part wraps most fields in DeepImmutable<{...}>. The second part, appended via &, holds fields that are explicitly excluded from DeepImmutable.

DeepImmutable<T> is defined in src/types/utils.ts as a recursive mapped type:

typescript
// Conceptual definition — actual source in src/types/utils.ts
export type DeepImmutable<T> = T extends (...args: unknown[]) => unknown
  ? T   // Functions pass through unchanged
  : { readonly [K in keyof T]: DeepImmutable<T[K]> }

Every nested level becomes readonly. An Array<string> becomes ReadonlyArray<string>. An object { a: { b: number } } becomes { readonly a: { readonly b: number } }. Function types pass through as-is, because adding readonly to function parameters would create false type errors at call sites without providing any runtime safety.

The practical consequence: TypeScript will refuse to compile any direct mutation of a DeepImmutable field. The only way to change AppState is through setState(prev => ({ ...prev, changedField: newValue })), which produces a new object and satisfies the immutability constraint.

Why tasks is excluded

The comment in the source is explicit: TaskState contains function types. Specifically, each task state record includes the kill callback — a function stored in the state object so the task manager can call it polymorphically regardless of which concrete task type it holds. If tasks were inside DeepImmutable<{...}>, the type system would recurse into the function type and mark its parameters as readonly. This is meaningless at runtime, produces confusing compiler errors, and does not add any actual safety. The exclusion is a pragmatic type-level carve-out.

The same reasoning applies to agentNameRegistry (a Map, and JavaScript Map methods are not compatible with deep immutability), and to the mcp and plugins sub-objects which hold live connection handles and loaded module references.

Representative fields

The fields inside DeepImmutable cover the full surface of session state:

typescript
// src/state/AppStateStore.ts (selected fields, abbreviated)
settings: SettingsJson                    // Merged settings from all sources
verbose: boolean                          // --verbose flag for this session
mainLoopModel: ModelSetting              // Currently selected model
statusLineText: string | undefined       // Status bar override text
expandedView: 'none' | 'tasks' | 'teammates'  // Which panel is expanded
isBriefOnly: boolean                     // Whether to suppress task details
toolPermissionContext: ToolPermissionContext  // Current permission mode + rules
kairosEnabled: boolean                   // Feature flag for Kairos mode
remoteConnectionStatus: 'connecting' | 'connected' | 'reconnecting' | 'disconnected'
replBridgeEnabled: boolean               // Bridge connection flag
replBridgeConnected: boolean
replBridgeSessionActive: boolean

Lower down, also under DeepImmutable:

typescript
thinkingEnabled: boolean | undefined
sessionHooks: SessionHooksState
teamContext?: { teamName: string; teammates: { ... } }
inbox: { messages: Array<{ id: string; from: string; text: string; ... }> }
workerSandboxPermissions: { queue: Array<...>; selectedIndex: number }
todos: { [agentId: string]: TodoList }
notifications: { current: Notification | null; queue: Notification[] }
attribution: AttributionState
fileHistory: FileHistoryState

The breadth of AppState is a direct reflection of the application's complexity: every piece of data that must survive across multiple React renders — and must be observable by the React component tree — lives here.


src/state/AppState.tsx: React Integration

AppState.tsx is the React integration layer. It connects the custom store to the React component tree using standard React patterns and exposes a set of hooks for reading and writing state.

AppStateProvider

typescript
// src/state/AppState.tsx (source form, before React compiler transform)
export const AppStoreContext = React.createContext<AppStateStore | null>(null)

const HasAppStateContext = React.createContext<boolean>(false)

export function AppStateProvider({ children, initialState, onChangeAppState }: Props) {
  // Prevent accidental nesting
  const hasAppStateContext = useContext(HasAppStateContext)
  if (hasAppStateContext) {
    throw new Error("AppStateProvider can not be nested within another AppStateProvider")
  }

  // Store is created once and never changes -- stable context value means
  // the provider never triggers re-renders. Consumers subscribe to slices
  // via useSyncExternalStore in useAppState(selector).
  const [store] = useState(
    () => createStore(initialState ?? getDefaultAppState(), onChangeAppState)
  )

  // ... (mount effect for bypass-permissions race condition) ...

  return (
    <HasAppStateContext.Provider value={true}>
      <AppStoreContext.Provider value={store}>
        <MailboxProvider>
          <VoiceProvider>{children}</VoiceProvider>
        </MailboxProvider>
      </AppStoreContext.Provider>
    </HasAppStateContext.Provider>
  )
}

Several choices here are non-obvious.

The store is created once inside useState. The factory function () => createStore(...) is passed to useState, which guarantees it runs exactly once — on the first render. Because the store reference never changes, AppStoreContext.Provider always receives the same value prop. This means the context value is stable across all re-renders of AppStateProvider itself, and no child component re-renders due to the context value changing. The store's subscribe/getState mechanism handles all re-render scheduling independently of the context system.

HasAppStateContext prevents nesting. A second AppStateProvider inside the first would create a new, isolated store. Any useAppState hook in the inner subtree would read from the inner store, not the outer one. If the same component tree was later moved outside the inner provider, it would silently start reading from the outer store — a class of bugs that is almost impossible to diagnose. The explicit throw makes this mistake immediately visible.

The mount effect handles a race condition. The useEffect on mount checks whether bypassPermissionsMode should be disabled. This is needed because remote settings may load before React mounts. When the remote settings fetch completes, it fires a change notification — but if no listeners are subscribed yet (because AppStateProvider has not mounted), the notification is lost. The mount effect re-reads the current policy and corrects the store if necessary.

useSyncExternalStore: the bridge to concurrent React

AppState.tsx exports four hooks. The most important is useAppState:

typescript
// src/state/AppState.tsx:142-163
export function useAppState<T>(selector: (state: AppState) => T): T {
  const store = useAppStore()

  const get = () => {
    const state = store.getState()
    const selected = selector(state)
    return selected
  }

  return useSyncExternalStore(store.subscribe, get, get)
}

useSyncExternalStore is React 18's official API for connecting external (non-React) stores to the concurrent-mode reconciler. It takes three arguments: a subscribe function, a getSnapshot function for the client, and a getSnapshot function for server rendering. The hook re-renders the component when subscribe fires a notification AND the snapshot value has changed (compared via Object.is).

The practical consequence: a component that calls useAppState(s => s.verbose) re-renders only when verbose changes. A component that calls useAppState(s => s.mainLoopModel) re-renders only when mainLoopModel changes. These two components are completely independent even though they share the same underlying store. This is selector-based fine-grained reactivity, achieved without a heavy framework.

The source comment for useAppState includes a critical usage warning:

Do NOT return new objects from the selector -- Object.is will always see
them as changed. Instead, select an existing sub-object reference:
const { text, promptId } = useAppState(s => s.promptSuggestion) // good

Returning a new object literal from the selector — useAppState(s => ({ a: s.a, b: s.b })) — means every store notification produces a new reference, and useSyncExternalStore sees a "change" on every single notification. The component would re-render on every setState call anywhere in the application, regardless of whether the selected values actually changed. The correct approach is to select a sub-object by reference, or to call the hook twice for two independent fields.

The remaining hooks

typescript
// src/state/AppState.tsx:170-178
export function useSetAppState() {
  return useAppStore().setState
}

export function useAppStateStore() {
  return useAppStore()
}

useSetAppState returns store.setState directly. Because store is created once and never replaced, store.setState is a stable function reference. A component that only calls useSetAppState() — and never calls useAppState() — will never re-render due to state changes. This is the correct pattern for action-only components (buttons, input handlers) that write state but never display it.

useAppStateStore returns the full store. This is for non-React code that needs to be passed both getState and setState — for example, assembling the ToolUseContext fields before the React tree is consulted.


src/state/selectors.ts: Pure State Derivations

Selectors live in their own file and have one rule: no side effects.

typescript
// src/state/selectors.ts:18-40
export function getViewedTeammateTask(
  appState: Pick<AppState, 'viewingAgentTaskId' | 'tasks'>,
): InProcessTeammateTaskState | undefined {
  const { viewingAgentTaskId, tasks } = appState

  if (!viewingAgentTaskId) return undefined

  const task = tasks[viewingAgentTaskId]
  if (!task) return undefined

  if (!isInProcessTeammateTask(task)) return undefined

  return task
}

getViewedTeammateTask accepts a Pick<AppState, ...> rather than the full AppState. This is a precision signal: the function declares exactly which fields it needs. The TypeScript compiler enforces this at call sites — you cannot accidentally pass a stale copy of just two fields and have the function silently use fields that are missing. It also makes the function trivially testable: construct a minimal object with viewingAgentTaskId and tasks, call the function, assert the result.

typescript
// src/state/selectors.ts:46-76
export type ActiveAgentForInput =
  | { type: 'leader' }
  | { type: 'viewed'; task: InProcessTeammateTaskState }
  | { type: 'named_agent'; task: LocalAgentTaskState }

export function getActiveAgentForInput(appState: AppState): ActiveAgentForInput {
  const viewedTask = getViewedTeammateTask(appState)
  if (viewedTask) {
    return { type: 'viewed', task: viewedTask }
  }

  const { viewingAgentTaskId, tasks } = appState
  if (viewingAgentTaskId) {
    const task = tasks[viewingAgentTaskId]
    if (task?.type === 'local_agent') {
      return { type: 'named_agent', task }
    }
  }

  return { type: 'leader' }
}

getActiveAgentForInput computes a discriminated union that determines where the next user message should be routed. When the user is viewing a teammate agent, input goes to that agent. When they are viewing a local agent by name, input goes to that agent. Otherwise, input goes to the session leader. The selector wraps this routing logic in a single testable function rather than scattering the conditional chain across the REPL input handler.

The module-level comment Keep selectors pure and simple - just data extraction, no side effects is an architectural constraint. Selectors that perform I/O, mutate state, or call external APIs would break the fundamental guarantee that they can be called at any time from any context — including from inside useSyncExternalStore's getSnapshot function, which React may call during render.


src/state/onChangeAppState.ts: The Side-Effect Hub

The onChange callback parameter of createStore accepts a function that fires whenever the state changes. In Claude Code's interactive path, this is onChangeAppState from src/state/onChangeAppState.ts.

This file exists to solve a specific historical bug. The comment in the source describes it directly:

typescript
// src/state/onChangeAppState.ts:50-64
// toolPermissionContext.mode — single choke point for CCR/SDK mode sync.
//
// Prior to this block, mode changes were relayed to CCR by only 2 of 8+
// mutation paths: a bespoke setAppState wrapper in print.ts (headless/SDK
// mode only) and a manual notify in the set_permission_mode handler.
// Every other path — Shift+Tab cycling, ExitPlanModePermissionRequest
// dialog options, the /plan slash command, rewind, the REPL bridge's
// onSetPermissionMode — mutated AppState without telling
// CCR, leaving external_metadata.permission_mode stale and the web UI out
// of sync with the CLI's actual mode.
//
// Hooking the diff here means ANY setAppState call that changes the mode
// notifies CCR (via notifySessionMetadataChanged → ccrClient.reportMetadata)
// and the SDK status stream (via notifyPermissionModeChanged → registered
// in print.ts). The scattered callsites above need zero changes.

Before onChangeAppState existed, there were at least 8 different code paths that could change the permission mode — Shift+Tab cycling in the UI, the /plan command, the ExitPlanMode dialog, the bridge handler, and so on. Only two of them remembered to notify the external control runtime (CCR) and the SDK status stream. The result was a stale-state bug: the web UI could display "plan mode" while the CLI had already transitioned back to default.

The fix was architectural. Rather than auditing every callsite and adding the notification manually, onChangeAppState observes the before/after state diff centrally. Any transition of toolPermissionContext.mode — regardless of which of the 8+ paths caused it — is now caught in one place:

typescript
// src/state/onChangeAppState.ts:65-92
const prevMode = oldState.toolPermissionContext.mode
const newMode = newState.toolPermissionContext.mode
if (prevMode !== newMode) {
  const prevExternal = toExternalPermissionMode(prevMode)
  const newExternal = toExternalPermissionMode(newMode)
  if (prevExternal !== newExternal) {
    const isUltraplan =
      newExternal === 'plan' &&
      newState.isUltraplanMode &&
      !oldState.isUltraplanMode
        ? true
        : null
    notifySessionMetadataChanged({
      permission_mode: newExternal,
      is_ultraplan_mode: isUltraplan,
    })
  }
  notifyPermissionModeChanged(newMode)
}

The externalization step toExternalPermissionMode(prevMode) is important. Internal modes like bubble and auto externalize to their public equivalents. The CCR notification fires only when the external representation changed — a transition from default to bubble back to default is two internal changes but zero external changes from CCR's perspective, so CCR is not needlessly notified.

The rest of onChangeAppState handles further state-synchronization concerns:

typescript
// src/state/onChangeAppState.ts:94-170 (abbreviated)

// mainLoopModel: persist or clear from user settings when model changes
if (newState.mainLoopModel !== oldState.mainLoopModel) {
  updateSettingsForSource('userSettings', { model: newState.mainLoopModel ?? undefined })
  setMainLoopModelOverride(newState.mainLoopModel)
}

// expandedView: persist as showExpandedTodos + showSpinnerTree for backward compat
if (newState.expandedView !== oldState.expandedView) {
  saveGlobalConfig(current => ({
    ...current,
    showExpandedTodos: newState.expandedView === 'tasks',
    showSpinnerTree: newState.expandedView === 'teammates',
  }))
}

// verbose: persist to global config
if (newState.verbose !== oldState.verbose) {
  saveGlobalConfig(current => ({ ...current, verbose: newState.verbose }))
}

// settings: clear auth caches and re-apply env vars when settings object changes
if (newState.settings !== oldState.settings) {
  clearApiKeyHelperCache()
  clearAwsCredentialsCache()
  clearGcpCredentialsCache()
  if (newState.settings.env !== oldState.settings.env) {
    applyConfigEnvironmentVariables()
  }
}

Each block addresses a cross-cutting concern that would otherwise require every callsite that changes a given field to remember to perform the corresponding side effect. The mainLoopModel block ensures that a model change from any source — a slash command, a UI picker, a bridge message — is persisted to userSettings and synced to bootstrap/state.ts. The settings block ensures credential caches are cleared whenever the merged settings object changes reference, so stale API keys are never used after settings reload.


src/bootstrap/state.ts: The Process-Level Singleton

bootstrap/state.ts is a plain module that exports getter and setter functions over a module-level object. The file's first substantive comment says:

typescript
// src/bootstrap/state.ts:31
// DO NOT ADD MORE STATE HERE - BE JUDICIOUS WITH GLOBAL STATE

This is a genuine architectural constraint, not decorative commentary. Adding state to this module affects every execution path — interactive, headless, SDK, sub-agent — because the module is loaded once per process and its object persists for the full process lifetime. The comment reflects a design philosophy: use this file only when a value must survive the React tree being torn down, must be accessible without threading a context object, or must be shared across multiple independent sub-agents that each have their own AppState stores.

The state object itself is a direct module-level variable:

typescript
// src/bootstrap/state.ts (pattern, not verbatim)
const stateInstance = { ...initialState }

export function getSessionId(): SessionId { return stateInstance.sessionId }
export function switchSession(newId: SessionId): void { stateInstance.sessionId = newId }

export function getTotalCostUSD(): number { return stateInstance.totalCostUSD }
export function addTotalCostUSD(amount: number): void {
  stateInstance.totalCostUSD += amount
}

There is no reactivity. No notifications. Callers read the value and get whatever it currently is. If a sub-agent and the main session are both adding cost, both call addTotalCostUSD and the increments accumulate in the shared object. This shared accumulation is intentional — the cost displayed in the status bar reflects the total session cost including all sub-agents.

The categories of state in this file are distinct from AppState:

Session identity: sessionId, parentSessionId, originalCwd, projectRoot. These are set once at startup, referenced throughout, and never changed during a normal session.

Accumulators: totalCostUSD, totalAPIDuration, totalAPIDurationWithoutRetries, totalToolDuration, totalLinesAdded, totalLinesRemoved, modelUsage. These accumulate throughout the session and are read at shutdown for the exit summary.

Model configuration: mainLoopModelOverride, initialMainLoopModel, modelStrings. The override is written by onChangeAppState whenever the user changes models in AppState, keeping the two tiers in sync.

Telemetry handles: meter, sessionCounter, locCounter, costCounter, tokenCounter, loggerProvider, meterProvider, tracerProvider. The OpenTelemetry objects are created once after the trust dialog and persist forever. They must be accessible from anywhere in the codebase that emits a metric, without threading a context object.

Session flags: isInteractive, sessionBypassPermissionsMode, sessionTrustAccepted, scheduledTasksEnabled, sessionPersistenceDisabled. These are read by many modules that cannot receive a context object (module initialization code, top-level utility functions).

Infrastructure caches: agentColorMap, lastAPIRequest, registeredHooks, invokedSkills, systemPromptSectionCache. Data that must survive React re-renders and is accessed from multiple unrelated modules.


Responsibility Boundary: A Decision Table

When you add new state to Claude Code, you must decide which tier it belongs in. The following table maps the key dimensions:

Dimensionbootstrap/state.tsAppState (store + AppStateStore.ts)
LifetimeProcess lifetimeSession lifetime (React tree)
Access patternSync functions getXxx() / setXxx()store.getState() or React hooks
ReactivityNone — no notificationsTriggers React re-renders via useSyncExternalStore
Immutability enforcementNone — directly mutable objectDeepImmutable<> on most fields
Typical contentssessionId, cost, telemetry, credentialsUI state, permission mode, MCP, tasks
Sub-agent inheritanceShared — sub-agents read totalCostUSDNot shared — each sub-agent has its own store
Side-effect dispatchNoneonChangeAppState fires on every state change

A simple heuristic: if changing the value should update the terminal display immediately, it belongs in AppState. If it is process-wide infrastructure, a stat accumulator, or a value that must be readable from module initialization code without a context object, it belongs in bootstrap/state.ts.


Adding a New Field to AppState: The Complete Pattern

To make the acceptance criteria concrete, here is the complete sequence for adding a hypothetical isCompacting: boolean field to AppState.

Step 1: Add the field to the type in AppStateStore.ts.

Add it inside the DeepImmutable<{...}> block, since it is a plain boolean with no function type issues:

typescript
// src/state/AppStateStore.ts — inside DeepImmutable<{...}>
isCompacting: boolean

Step 2: Add a default value in getDefaultAppState().

getDefaultAppState is defined later in AppStateStore.ts and returns an AppState with all fields set to sensible defaults. Add the new field:

typescript
isCompacting: false,

Step 3: Update the field from a tool or query function.

Tools receive setAppState via ToolUseContext. The update must produce a new object:

typescript
// Inside a tool's call() method
context.setAppState(prev => ({ ...prev, isCompacting: true }))
// ... perform compaction ...
context.setAppState(prev => ({ ...prev, isCompacting: false }))

Step 4: Read the field in a React component.

typescript
const isCompacting = useAppState(s => s.isCompacting)

This component will re-render exactly when isCompacting changes and at no other time.

Step 5: Add a side effect in onChangeAppState.ts if needed.

If changing isCompacting should have an external consequence — say, notifying a bridge connection — add a block to onChangeAppState:

typescript
if (newState.isCompacting !== oldState.isCompacting) {
  notifyCompactionStatusChanged(newState.isCompacting)
}

This is the complete pattern. No other files need to be touched. The type system enforces the immutability contract at every step. The reactivity, the side effects, and the external notification are all automatic consequences of writing through the store.


Key Takeaways

The two-tier state architecture is not an accident of growth. It is a deliberate separation of two fundamentally incompatible concerns: process-lifetime infrastructure that needs no reactivity, and session-lifetime UI state that needs fine-grained reactivity.

src/state/store.ts is 35 lines that implement the essential subset of a Zustand-style store: immutable snapshots, reference-equality bail-out, ordered notification (side effects before listeners), and stable unsubscribe teardown. Its brevity is the point — the implementation is small enough to read and reason about completely.

DeepImmutable<T> enforces the immutability contract across 150+ fields without requiring readonly annotations on every individual field. The intersection type & { tasks: ...; mcp: ...; } is a precise surgical carve-out for the fields that contain function types or live connection handles, where deep immutability would produce meaningless or actively harmful type errors.

useSyncExternalStore is the load-bearing connection between the custom store and React's concurrent-mode reconciler. The selector pattern in useAppState(selector) ensures that components re-render only when the specific slice of state they care about changes — not on every store mutation in the entire application.

onChangeAppState centralizes all cross-cutting side effects of state changes. Its existence fixed a class of stale-state bugs where scattered mutation paths each had to remember to notify external consumers. The single diff observer removes that obligation from every callsite by making it impossible for a mode change to go unnoticed.

src/bootstrap/state.ts is the explicit pressure valve for state that truly cannot live in the reactive tier: values that must survive across React tree resets, values that must be accessible from module initialization code without dependency injection, and values that must be shared by all sub-agents because they accumulate across the full process lifetime.


Next: Chapter 05 traces the inner agentic loop in src/query.ts — the AsyncGenerator pipeline that drives streaming API calls, tool dispatch, and context compaction.

Built for learners who want to read Claude Code like a real system.