Studio Notebook

Claude Code Atlas

App State Provider And Store Bridge

See how the provider and tiny store let React subscribe to app state without over-rendering.

Why this matters

The store is tiny on purpose. It only needs to hold the current AppState snapshot, let listeners subscribe, and call one change hook when a snapshot is replaced.

That sounds small because it is small. The point is not to build a second state framework inside the app. The point is to let React read shared shell state in a way that stays predictable and does not turn the whole app into one giant re-render.

This chapter walks the bridge in the same order the code does:

  1. AppStateProvider creates the store once and shares it through context.
  2. useAppState(selector) subscribes to slices instead of returning the whole state.
  3. useSetAppState() and useAppStateStore() hand out the write and escape hatches.
  4. onChangeAppState() centralizes the side effects that must follow a state change.

The provider creates one stable store

AppStateProvider is the narrow entrance into the shared shell state. It builds the store once, keeps that store identity stable, and exposes it to the rest of the app through React context.

The AppState.tsx file shown here is compiler-emitted, so focus on the state flow (createStore, useState, useSyncExternalStore) rather than the cache-slot variables.

export function AppStateProvider(t0) {
  const $ = _c(13);
  const {
    children,
    initialState,
    onChangeAppState
  } = t0;
  const hasAppStateContext = useContext(HasAppStateContext);
  if (hasAppStateContext) {
    throw new Error("AppStateProvider can not be nested within another AppStateProvider");
  }
  let t1;
  if ($[0] !== initialState || $[1] !== onChangeAppState) {
    t1 = () => createStore(initialState ?? getDefaultAppState(), onChangeAppState);
    $[0] = initialState;
    $[1] = onChangeAppState;
    $[2] = t1;
  } else {
    t1 = $[2];
  }
  const [store] = useState(t1);

The important detail is the createStore(initialState ?? getDefaultAppState(), onChangeAppState); call. AppStateProvider does not rebuild the store on every render. It seeds one store up front and then lets the rest of the app talk to that store.

Subscribe to slices, not the whole object

useAppState(selector) is the part that keeps the UI efficient. It does not return the whole AppState object and force every consumer to re-render on every change. It subscribes to the slice the selector returns.

That is why the hook is written around useSyncExternalStore(store.subscribe, get, get);. Each component asks for only the piece it needs. If two fields are independent, the code should call the hook twice instead of reading one big object and re-sorting it locally.

In other words, useAppState(selector) subscribes to slices instead of returning the whole state.

/**
 * Subscribe to a slice of AppState. Only re-renders when the selected value
 * changes (compared via Object.is).
 *
 * For multiple independent fields, call the hook multiple times:
 * ```
 * const verbose = useAppState(s => s.verbose)
 * const model = useAppState(s => s.mainLoopModel)
 * ```
 *
 * 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
 * ```
 */
export function useAppState(selector) {
  const $ = _c(3);
  const store = useAppStore();
  let t0;
  if ($[0] !== selector || $[1] !== store) {
    t0 = () => {
      const state = store.getState();
      const selected = selector(state);
      if (false && state === selected) {
        throw new Error(`Your selector in \`useAppState(${selector.toString()})\` returned the original state, which is not allowed. You must instead return a property for optimised rendering.`);
      }
      return selected;
    };
    $[0] = selector;
    $[1] = store;
    $[2] = t0;
  } else {
    t0 = $[2];
  }
  const get = t0;
  return useSyncExternalStore(store.subscribe, get, get);
}

That is the core reason this page exists: useAppState(selector) subscribes to slices instead of returning the whole state, so a task badge, a permission indicator, and a prompt preview can update independently.

The write path stays simple

The write API is just as small. useSetAppState() returns the updater, and useAppStateStore() returns the raw store for places that need getState() or setState() outside React.

/**
 * Get the setAppState updater without subscribing to any state.
 * Returns a stable reference that never changes -- components using only
 * this hook will never re-render from state changes.
 */
export function useSetAppState() {
  return useAppStore().setState;
}

/**
 * Get the store directly (for passing getState/setState to non-React code).
 */
export function useAppStateStore() {
  return useAppStore();
}

The store reports changes once

The store itself is intentionally plain. It compares the previous and next snapshot with Object.is, skips no-op writes, and then calls the change hook once before notifying listeners.

    setState: (updater: (prev: T) => T) => {
      const prev = state
      const next = updater(prev)
      if (Object.is(next, prev)) return
      state = next
      onChange?.({ newState: next, oldState: prev })
      for (const listener of listeners) listener()
    },

That tiny shape is enough because the side effects are not hidden inside the store. They live in onChangeAppState(), which is where the shell keeps its metadata and permission mode in sync.

export function onChangeAppState({
  newState,
  oldState,
}: {
  newState: AppState
  oldState: AppState
}) {
  // 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.
  const prevMode = oldState.toolPermissionContext.mode
  const newMode = newState.toolPermissionContext.mode
  if (prevMode !== newMode) {
    // CCR external_metadata must not receive internal-only mode names
    // (bubble, ungated auto). Externalize first — and skip
    // the CCR notify if the EXTERNAL mode didn't change (e.g.,
    // default→bubble→default is noise from CCR's POV since both
    // externalize to 'default'). The SDK channel (notifyPermissionModeChanged)
    // passes raw mode; its listener in print.ts applies its own filter.
    const prevExternal = toExternalPermissionMode(prevMode)
    const newExternal = toExternalPermissionMode(newMode)
    if (prevExternal !== newExternal) {
      // Ultraplan = first plan cycle only. The initial control_request
      // sets mode and isUltraplanMode atomically, so the flag's
      // transition gates it. null per RFC 7396 (removes the key).
      const isUltraplan =
        newExternal === 'plan' &&
        newState.isUltraplanMode &&
        !oldState.isUltraplanMode
          ? true
          : null
      notifySessionMetadataChanged({
        permission_mode: newExternal,
        is_ultraplan_mode: isUltraplan,
      })
    }
    notifyPermissionModeChanged(newMode)
  }

That is the whole bridge in plain English: one tiny store, slice-based reads, one updater, and one change hook for the cross-cutting side effects.