State Management: React State Architecture Without Redux

A deep dive into Claude Code's minimalist state management -- a custom Store in under 35 lines, Object.is change detection, and fine-grained subscriptions

The Problem

React state management is a perennial topic in frontend engineering. From Redux to MobX, from Zustand to Jotai, state management libraries keep multiplying. Yet Claude Code chose a surprising path -- implementing a complete state management system in under 35 lines of code, without introducing any third-party library.

This is not toy code. Claude Code's AppState contains over 80 fields, spanning settings, MCP connections, plugins, permissions, bridge state, team collaboration, and more. How can a 35-line Store support such a massive state tree? Why did it choose not to use Redux? What tradeoffs lie behind this decision?

The Complete Store Implementation

Let's start with the code. Here is the entire contents of src/state/store.ts:

src/state/store.ts
TypeScript
1// Lines 1-34
2type Listener = () => void
3type OnChange<T> = (args: { newState: T; oldState: T }) => void
4
5export type Store<T> = {
6 getState: () => T
7 setState: (updater: (prev: T) => T) => void
8 subscribe: (listener: Listener) => () => void
9}
10
11export function createStore<T>(
12 initialState: T,
13 onChange?: OnChange<T>,
14): Store<T> {
15 let state = initialState
16 const listeners = new Set<Listener>()
17
18 return {
19 getState: () => state,
20
21 setState: (updater: (prev: T) => T) => {
22 const prev = state
23 const next = updater(prev)
24 if (Object.is(next, prev)) return
25 state = next
26 onChange?.({ newState: next, oldState: prev })
27 for (const listener of listeners) listener()
28 },
29
30 subscribe: (listener: Listener) => {
31 listeners.add(listener)
32 return () => listeners.delete(listener)
33 },
34 }
35}

34 lines. No middleware, no devtools, no immer. Let's break down layer by layer why this is already enough.

Architecture Overview

...

Three Core APIs

  1. getState() -- Synchronously retrieves the current state snapshot with zero overhead.
  2. setState(updater) -- Accepts a pure function (prev) => next. If Object.is(next, prev) is true, nothing happens.
  3. subscribe(listener) -- Registers a parameterless callback and returns an unsubscribe function.

This perfectly matches the contract of useSyncExternalStore -- the Hook that React 18 specifically designed for this kind of external Store.

Object.is Change Detection: The Depth Behind One Line

TypeScript
1// src/state/store.ts Line 23
2if (Object.is(next, prev)) return

This line looks simple but carries profound implications.

Object.is performs a reference equality comparison -- if the updater returns the same object reference, the state is considered unchanged. This means:

  1. Immutable updates are enforced. If you want to change state, you must return a new object: prev => ({ ...prev, verbose: true }).
  2. Zero cost when nothing changes. If the updater determines no update is needed, it can simply return prev, and the Store won't trigger any notifications.
  3. No deep comparison overhead. Redux's shallowEqual, Zustand's Object.is selector comparison -- Claude Code places this check at the topmost level.

Here's a real-world optimization example from enterTeammateView in src/state/teammateViewHelpers.ts:

TypeScript
1// src/state/teammateViewHelpers.ts Lines 51-80
2export function enterTeammateView(
3 taskId: string,
4 setAppState: (updater: (prev: AppState) => AppState) => void,
5): void {
6 logEvent('tengu_transcript_view_enter', {})
7 setAppState(prev => {
8 const task = prev.tasks[taskId]
9 const prevId = prev.viewingAgentTaskId
10 const prevTask = prevId !== undefined ? prev.tasks[prevId] : undefined
11 const switching =
12 prevId !== undefined &&
13 prevId !== taskId &&
14 isLocalAgent(prevTask) &&
15 prevTask.retain
16 const needsRetain =
17 isLocalAgent(task) && (!task.retain || task.evictAfter !== undefined)
18 const needsView =
19 prev.viewingAgentTaskId !== taskId ||
20 prev.viewSelectionMode !== 'viewing-agent'
21 // Key: if nothing needs to change, return prev directly
22 if (!needsRetain && !needsView && !switching) return prev
23 // ...construct new state
24 })
25}

The if (!needsRetain && !needsView && !switching) return prev on line 66 is a common pattern -- performing conditional checks inside the updater to avoid unnecessary state updates. Thanks to the Object.is check, returning prev means zero side effects.

React Context and useSyncExternalStore

The consumer side of the Store is implemented in src/state/AppState.tsx.

The Provider Layer

TypeScript
1// src/state/AppState.tsx Lines 27-28, 37-110
2export const AppStoreContext = React.createContext<AppStateStore | null>(null)
3
4export function AppStateProvider({ children, initialState, onChangeAppState }) {
5 // Prevent nesting
6 const hasAppStateContext = useContext(HasAppStateContext)
7 if (hasAppStateContext) {
8 throw new Error("AppStateProvider can not be nested within another AppStateProvider")
9 }
10
11 const [store] = useState(
12 () => createStore(initialState ?? getDefaultAppState(), onChangeAppState)
13 )
14
15 // Check bypass permissions state on initial mount
16 useEffect(() => {
17 const { toolPermissionContext } = store.getState()
18 if (toolPermissionContext.isBypassPermissionsModeAvailable &&
19 isBypassPermissionsModeDisabled()) {
20 store.setState(prev => ({
21 ...prev,
22 toolPermissionContext: createDisabledBypassPermissionsContext(
23 prev.toolPermissionContext
24 )
25 }))
26 }
27 }, [])
28
29 // Watch for settings file changes
30 const onSettingsChange = useEffectEvent(
31 source => applySettingsChange(source, store.setState)
32 )
33 useSettingsChange(onSettingsChange)
34
35 return (
36 <HasAppStateContext.Provider value={true}>
37 <AppStoreContext.Provider value={store}>
38 <MailboxProvider>
39 <VoiceProvider>{children}</VoiceProvider>
40 </MailboxProvider>
41 </AppStoreContext.Provider>
42 </HasAppStateContext.Provider>
43 )
44}

Key design decisions:

  1. The Store is created inside useState with lazy initialization, ensuring only one Store instance exists throughout the application lifecycle.
  2. Nesting detection -- HasAppStateContext prevents accidentally creating multiple Providers.
  3. onChangeAppState callback -- Injected at creation time to handle side effects of state changes.
  4. useSettingsChange -- Watches for external changes to configuration files (filesystem, environment variables) and injects them into the Store.

The useAppState Hook

TypeScript
1// src/state/AppState.tsx Lines 117-160
2function useAppStore(): AppStateStore {
3 const store = useContext(AppStoreContext)
4 if (!store) {
5 throw new ReferenceError(
6 'useAppState/useSetAppState cannot be called outside of an <AppStateProvider />'
7 )
8 }
9 return store
10}
11
12/**
13 * Subscribe to a slice of AppState. Only re-renders when
14 * the selected value changes (via Object.is comparison).
15 */
16export function useAppState(selector) {
17 const store = useAppStore()
18 const getSnapshot = () => {
19 const state = store.getState()
20 const selected = selector(state)
21 return selected
22 }
23 return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot)
24}

This is the core consumer API for the entire state management system. useSyncExternalStore is a low-level Hook introduced in React 18 that accepts three parameters:

  1. subscribe -- Register for change notifications
  2. getSnapshot -- Get the current value
  3. getServerSnapshot -- SSR snapshot (reuses getSnapshot here)

When the Store emits a notification, React calls getSnapshot to get the new value and compares it with the previous one via Object.is. If the values are the same, rendering is skipped; if different, a component re-render is triggered.

This enables fine-grained update capabilities:

TypeScript
1// Only re-renders when verbose changes
2const verbose = useAppState(s => s.verbose)
3
4// Only re-renders when the model changes
5const model = useAppState(s => s.mainLoopModel)
6
7// Reference-stable sub-object -- only re-renders when the promptSuggestion reference changes
8const { text, promptId } = useAppState(s => s.promptSuggestion)

Complete Data Flow Path

sequenceDiagram
  participant User as User Action
  participant Component as React Component
  participant Store as Store<AppState>
  participant OnChange as onChangeAppState
  participant SideEffects as Side Effects

  User->>Component: Interaction (toggle model/settings)
  Component->>Store: setState(prev => newState)
  Store->>Store: Object.is(next, prev)?
  alt State unchanged
    Store-->>Component: Do nothing
  else State changed
    Store->>OnChange: { newState, oldState }
    OnChange->>SideEffects: Persist / sync CCR
    Store->>Component: Notify all subscribers
    Component->>Store: getSnapshot() via useSyncExternalStore
    Store-->>Component: selector(state)
    Component->>Component: Object.is comparison -> decide whether to re-render
  end

onChangeAppState: The Side Effect Layer for State Changes

src/state/onChangeAppState.ts is the implementation of the Store's onChange callback. This is the only centralized location in the entire system where state change side effects are handled.

TypeScript
1// src/state/onChangeAppState.ts Lines 43-171
2export function onChangeAppState({
3 newState,
4 oldState,
5}: {
6 newState: AppState
7 oldState: AppState
8}) {
9 // Sync permission mode to CCR and SDK
10 const prevMode = oldState.toolPermissionContext.mode
11 const newMode = newState.toolPermissionContext.mode
12 if (prevMode !== newMode) {
13 const prevExternal = toExternalPermissionMode(prevMode)
14 const newExternal = toExternalPermissionMode(newMode)
15 if (prevExternal !== newExternal) {
16 notifySessionMetadataChanged({
17 permission_mode: newExternal,
18 is_ultraplan_mode: isUltraplan,
19 })
20 }
21 notifyPermissionModeChanged(newMode)
22 }
23
24 // Persist model changes to settings
25 if (newState.mainLoopModel !== oldState.mainLoopModel) {
26 if (newState.mainLoopModel === null) {
27 updateSettingsForSource('userSettings', { model: undefined })
28 setMainLoopModelOverride(null)
29 } else {
30 updateSettingsForSource('userSettings', { model: newState.mainLoopModel })
31 setMainLoopModelOverride(newState.mainLoopModel)
32 }
33 }
34
35 // Persist expandedView
36 if (newState.expandedView !== oldState.expandedView) {
37 saveGlobalConfig(current => ({
38 ...current,
39 showExpandedTodos: newState.expandedView === 'tasks',
40 showSpinnerTree: newState.expandedView === 'teammates',
41 }))
42 }
43
44 // Persist verbose
45 if (newState.verbose !== oldState.verbose) {
46 saveGlobalConfig(current => ({ ...current, verbose: newState.verbose }))
47 }
48
49 // Clear auth caches when settings change
50 if (newState.settings !== oldState.settings) {
51 clearApiKeyHelperCache()
52 clearAwsCredentialsCache()
53 clearGcpCredentialsCache()
54 if (newState.settings.env !== oldState.settings.env) {
55 applyConfigEnvironmentVariables()
56 }
57 }
58}

The elegance of this pattern lies in its separation of concerns:

  1. The Store itself knows nothing about side effect logic.
  2. onChangeAppState acts as a pure diff-handler, executing only when state actually changes.
  3. Each side effect block is independent -- permission mode sync, model persistence, config cache clearing -- they don't interfere with each other.

Compared to Redux's middleware pattern, there are no action type strings, no dispatch chains, no sagas/thunks. Simply comparing oldState.x !== newState.x is clear and unambiguous.

AppState Structure Design

Let's look at the AppState type defined in src/state/AppStateStore.ts. It's a massive type definition spanning roughly 450 lines:

TypeScript
1// src/state/AppStateStore.ts Lines 89-158 (excerpt)
2export type AppState = DeepImmutable<{
3 settings: SettingsJson
4 verbose: boolean
5 mainLoopModel: ModelSetting
6 statusLineText: string | undefined
7 expandedView: 'none' | 'tasks' | 'teammates'
8 kairosEnabled: boolean
9 toolPermissionContext: ToolPermissionContext
10 replBridgeEnabled: boolean
11 replBridgeConnected: boolean
12 replBridgeSessionActive: boolean
13 // ... more bridge state fields
14}> & {
15 // Fields excluded from DeepImmutable (contain function types)
16 tasks: { [taskId: string]: TaskState }
17 agentNameRegistry: Map<string, AgentId>
18 mcp: {
19 clients: MCPServerConnection[]
20 tools: Tool[]
21 commands: Command[]
22 resources: Record<string, ServerResource[]>
23 pluginReconnectKey: number
24 }
25 plugins: {
26 enabled: LoadedPlugin[]
27 disabled: LoadedPlugin[]
28 commands: Command[]
29 errors: PluginError[]
30 installationStatus: { ... }
31 needsRefresh: boolean
32 }
33 // ... more fields
34}

Note the DeepImmutable<...> & { ... } structure:

  • DeepImmutable portion -- Simple value type fields where the TypeScript compiler guarantees immutability.
  • Non-DeepImmutable portion -- Fields containing function types (like AbortController) or special collections (like Map, Set), where immutability is managed manually.

Default State Factory

TypeScript
1// src/state/AppStateStore.ts Lines 456-569
2export function getDefaultAppState(): AppState {
3 const initialMode: PermissionMode =
4 teammateUtils.isTeammate() && teammateUtils.isPlanModeRequired()
5 ? 'plan'
6 : 'default'
7
8 return {
9 settings: getInitialSettings(),
10 tasks: {},
11 agentNameRegistry: new Map(),
12 verbose: false,
13 mainLoopModel: null,
14 toolPermissionContext: {
15 ...getEmptyToolPermissionContext(),
16 mode: initialMode,
17 },
18 mcp: {
19 clients: [],
20 tools: [],
21 commands: [],
22 resources: {},
23 pluginReconnectKey: 0,
24 },
25 plugins: {
26 enabled: [],
27 disabled: [],
28 commands: [],
29 errors: [],
30 installationStatus: { marketplaces: [], plugins: [] },
31 needsRefresh: false,
32 },
33 thinkingEnabled: shouldEnableThinkingByDefault(),
34 promptSuggestionEnabled: shouldEnablePromptSuggestion(),
35 // ... 30+ more default values
36 }
37}

Note that the default values are not hardcoded constants -- getInitialSettings() reads merged settings from the configuration system, and shouldEnableThinkingByDefault() determines whether to enable thinking mode based on the environment. This means the Store's initial state itself is dynamically computed.

The Selectors Pattern

src/state/selectors.ts demonstrates how to derive computed values from AppState:

TypeScript
1// src/state/selectors.ts Lines 18-40
2export function getViewedTeammateTask(
3 appState: Pick<AppState, 'viewingAgentTaskId' | 'tasks'>,
4): InProcessTeammateTaskState | undefined {
5 const { viewingAgentTaskId, tasks } = appState
6
7 if (!viewingAgentTaskId) return undefined
8 const task = tasks[viewingAgentTaskId]
9 if (!task) return undefined
10 if (!isInProcessTeammateTask(task)) return undefined
11
12 return task
13}
14
15// src/state/selectors.ts Lines 59-76
16export function getActiveAgentForInput(appState: AppState): ActiveAgentForInput {
17 const viewedTask = getViewedTeammateTask(appState)
18 if (viewedTask) {
19 return { type: 'viewed', task: viewedTask }
20 }
21
22 const { viewingAgentTaskId, tasks } = appState
23 if (viewingAgentTaskId) {
24 const task = tasks[viewingAgentTaskId]
25 if (task?.type === 'local_agent') {
26 return { type: 'named_agent', task }
27 }
28 }
29
30 return { type: 'leader' }
31}

Selectors use Pick<AppState, ...> to explicitly declare their dependencies -- this serves not only as type safety but also as documentation. You can see at a glance that getViewedTeammateTask depends on only two fields.

Comparison with Redux/Zustand

Redux
Action Creator
Dispatch
Middleware
Reducer
New State
Selector + Connect
Total ≈ 6 steps (parallel = faster)
Zustand
set()
Middleware
Immer Proxy
New State
useStore(selector)
Total ≈ 5 steps (parallel = faster)
FeatureReduxZustandClaude Code
Code size~2000 lines core~400 lines core34 lines
Action typesString constantsNot neededNot needed
MiddlewareChainedChainedonChange callback
ImmutabilityManual / ImmerOptional ImmerManual + TypeScript
DevToolsBuilt-inBuilt-inNone (not needed)
Change detectionshallowEqualObject.isObject.is
React integrationconnect/useSelectoruseStoreuseSyncExternalStore
Side effectssaga/thunkmiddlewareonChangeAppState
Dependenciesreact-reduxzustandZero dependencies

Why Not Redux?

  1. CLI applications don't need undo/redo -- Redux's action log provides no practical value in a CLI.
  2. No multi-store interactions -- Claude Code has only one global Store.
  3. No complex async flows -- No need for saga generators or thunk's nested dispatches.
  4. Startup performance -- A 35-line Store doesn't need to load any external dependencies.

Why Not Zustand?

Zustand is actually very close to Claude Code's design. But a careful comparison reveals:

  1. Zustand's set accepts partial state -- Claude Code enforces prev => next functional updates to prevent accidental overwrites.
  2. Zustand's subscribe supports selectors -- Claude Code places selectors at the useSyncExternalStore layer, closer to React's native model.
  3. Zero dependencies -- Claude Code's Store doesn't import any packages. For CLI application startup speed, every dependency saved is one less module to load.

Hot Reloading of Settings Changes

The useSettingsChange in AppStateProvider watches for configuration file changes:

TypeScript
1// src/state/AppState.tsx Lines 83-91
2const onSettingsChange = useEffectEvent(
3 source => applySettingsChange(source, store.setState)
4)
5useSettingsChange(onSettingsChange)

When a user edits ~/.claude/settings.json in another terminal, or an enterprise admin pushes a remote configuration:

  1. The filesystem watcher detects the change
  2. useSettingsChange triggers the callback
  3. applySettingsChange constructs a new settings object
  4. store.setState updates the state
  5. onChangeAppState detects that newState.settings !== oldState.settings
  6. Auth caches are cleared and environment variables are reapplied

The entire chain requires no manual event dispatching -- from file change to UI update, everything happens automatically.

DCE and Conditional Providers

AppState.tsx includes a feature flag-controlled conditional load:

TypeScript
1// src/state/AppState.tsx Lines 14-19
2const VoiceProvider: (props: {
3 children: React.ReactNode;
4}) => React.ReactNode = feature('VOICE_MODE')
5 ? require('../context/voice.js').VoiceProvider
6 : ({ children }) => children;

When the VOICE_MODE feature flag is disabled, VoiceProvider is replaced with a passthrough component. Bun's compiler replaces feature('VOICE_MODE') with false at build time, and then through dead code elimination, the entire require('../context/voice.js') never appears in the final bundle.

This means the Provider wrapper layer in AppStateProvider is variable -- depending on the build configuration, it may include or exclude Voice, Mailbox, and other contexts.

Performance Characteristics

Batch Updates

React 18 enables automatic batching by default. Multiple setState calls within the same event loop tick trigger only a single re-render. Claude Code's Store is naturally compatible with this mechanism -- after subscribe's listener fires, React's scheduler merges the renders.

Selective Subscriptions

TypeScript
1// Good: only re-renders when verbose changes
2const verbose = useAppState(s => s.verbose)
3
4// Bad: re-renders on every state change
5// const state = useAppState(s => s) // not allowed

The JSDoc for useAppState explicitly warns against returning the entire state object. The source code even includes a runtime check (enabled in development mode):

TypeScript
1// src/state/AppState.tsx Lines 150-152
2if (false && state === selected) {
3 throw new Error(
4 `Your selector returned the original state, which is not allowed.`
5 )
6}

if (false && ...) means this check is completely eliminated in production builds, but can be enabled during development by modifying the condition.

The No-New-Object Rule

The useAppState documentation emphasizes:

Do NOT return new objects from the selector -- Object.is will always see them as changed.

TypeScript
1// Good: select an existing sub-object reference
2const { text, promptId } = useAppState(s => s.promptSuggestion)
3
4// Bad: creates a new object every time
5// const data = useAppState(s => ({ text: s.promptSuggestion.text }))

This constraint stems from how useSyncExternalStore works -- every time the Store emits a notification, React calls getSnapshot and compares it with the previous return value via Object.is. If the selector returns a new object, Object.is will always be false, leading to infinite re-renders.

In Practice: The Complete State Update Path

Let's trace the complete path using toggling verbose mode as an example:

  1. User action: Toggle verbose in settings

  2. setState call:

TypeScript
1store.setState(prev => ({ ...prev, verbose: !prev.verbose }))
  1. Inside the Store:

    • Object.is(next, prev) -> false (new object)
    • Update the internal state reference
    • Call onChange({ newState: next, oldState: prev })
    • Iterate through listeners to notify
  2. onChangeAppState:

TypeScript
1if (newState.verbose !== oldState.verbose) {
2 saveGlobalConfig(current => ({ ...current, verbose: newState.verbose }))
3}
  1. React update:

    • useSyncExternalStore receives notification
    • Calls selector(store.getState()) to get the new verbose value
    • Object.is(true, false) -> false -> triggers re-render
  2. UI update: Component renders with the new verbose value

The entire process involves no action type strings, no reducer switch-cases, no middleware pipelines. A single setState call does all the work.

Summary

Claude Code's state management is a triumph of minimalism:

  • 34 lines of core code -- createStore provides complete subscription/update/detection functionality
  • Zero external dependencies -- No Redux, Zustand, or MobX imported
  • Object.is short-circuit -- Intercepts invalid updates at the earliest opportunity
  • useSyncExternalStore integration -- Leverages React 18's native API for fine-grained subscriptions
  • onChangeAppState centralized side effects -- Replaces middleware by handling all state synchronization in a single function

Not every project needs Redux. When your application has a single Store, doesn't need time-travel debugging, and doesn't need complex async flows, 34 lines of code is the best state management library.