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:
AppStateProvidercreates the store once and shares it through context.useAppState(selector)subscribes to slices instead of returning the whole state.useSetAppState()anduseAppStateStore()hand out the write and escape hatches.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.