Skip to main content

State & Sessions

How Claude Code tracks everything happening at runtime — the conversation, permissions, tasks, and UI — and how it persists across sessions.

The Core Idea

Imagine a shared whiteboard that every part of the app can read from and write to. That's the AppState store — a single object holding all runtime state. When something changes (a new message arrives, a tool finishes, the user toggles a setting), the whiteboard is updated, and only the parts of the UI that care about that change re-render.

This is the same pattern used by state management libraries like Zustand (React) or Pinia (Vue), but Claude Code implements it from scratch.

AppState Store

Click any group to expand its fields:

AppStatesingle source of truth — immutable updates only
💬Conversation 5 fields
messagesMessage[][user, assistant, tool_result, ...]
conversationIdstring"conv_a1b2c3d4"
mainLoopModelstring"claude-sonnet-4-20250514"
speculationStateSpeculationStateidle | speculating
promptSuggestion{ text, promptId }"Try /commit"
🔒Permissions 3 fields
toolPermissionContextToolPermissionContextmode: "default"
permissionModePermissionMode"default" | "auto" | ...
permissionRulesPermissionRule[][Bash(git *): allow]
📋Tasks 2 fields
🖥️UI State 4 fields
14 fields shown (50+ total)src/state/AppStateStore.ts

How It Works

The store has three operations: read, update, and subscribe. Here's the pattern in TypeScript and the Python equivalent:

// TypeScript (actual Claude Code pattern)

// 1. Read — get current state snapshot
const state = store.getState()
console.log(state.messages.length) // 12

// 2. Update — always create a NEW object (never mutate)
store.setState(prev => ({
...prev,
messages: [...prev.messages, newMessage] // new array, not push()
}))

// 3. Subscribe — React hook that re-renders only when selected value changes
function MessageList() {
const messages = useAppState(s => s.messages)
// ^^^^^^^^ only re-renders when messages array changes
return <>{messages.map(m => <Message key={m.id} msg={m} />)}</>
}
# Python equivalent — same concept, different syntax

# 1. Read
state = store.get_state()
print(len(state["messages"])) # 12

# 2. Update — return new dict, never mutate in place
store.set_state(lambda prev: {
**prev,
"messages": [*prev["messages"], new_message] # new list
})

# 3. Subscribe — callback fires only when selected value changes
def on_messages_change(messages):
render_message_list(messages)

store.subscribe(
selector=lambda s: s["messages"],
callback=on_messages_change
)

Why Immutable Updates?

The store uses Object.is (reference equality) to detect changes. This is why you always create a new object instead of mutating:

// ❌ BAD — mutation, store can't detect the change
state.messages.push(newMessage)

// ✅ GOOD — new array, store sees a different reference
setState(prev => ({
...prev,
messages: [...prev.messages, newMessage]
}))
# Same principle in Python:

# ❌ BAD
state["messages"].append(new_message)

# ✅ GOOD
new_state = {**state, "messages": [*state["messages"], new_message]}

This means if you update messages, only components subscribed to messages re-render. Components watching tasks or verbosity don't re-render at all. With 50+ fields in the store, this selectivity is critical for performance.

React Contexts

Beyond the main store, specialized React contexts handle concerns that don't belong in AppState:

ContextWhat It ManagesWhy Separate
NotificationContextToast messages and alertsEphemeral UI, not core state
MailboxContextCross-agent messaging (swarm mode)Only exists in multi-agent mode
ModalContextDialog stack (which modals are open)UI-only, no persistence needed
OverlayContextOverlay layersUI-only
PromptOverlayContextInteractive prompt dialogsUI-only
VoiceContextVoice recording stateFeature-gated, rarely active

These are nested as providers wrapping the entire app:

🧩
App
💾
AppStateProvider (the main store)
🔔
NotificationProvider
📦
ModalProvider
🪟
OverlayProvider
🖥️
REPL Screen (your terminal UI)

Each provider makes its state available to any component below it in the tree. A component deep inside the REPL can access notifications, modals, or the main store without prop drilling.

Session Persistence

When you close Claude Code and come back later with --resume, you pick up exactly where you left off. Here's how:

What Gets Saved to Disk

DataLocationFormat
Conversation messages~/.claude/sessions/<id>.jsonJSON array
Session metadataSame filemodel, start time, project path
Task states~/.claude/tasks/<id>.jsonJSON per task
Task outputs~/.claude/tasks/<id>.outputRaw text

Resume Flow

You run: claude --resume

1. Load session JSON from ~/.claude/sessions/<id>
2. Deserialize messages array
3. Restore into AppState store
4. React re-renders the conversation
5. You see your full history and continue

Session data is plain JSON — no database, no binary format. You can even inspect or edit session files manually if needed.

Framework Comparison

Every agentic framework needs to manage conversation state and persist sessions — but the approaches differ significantly.

src/state/AppStateStore.ts (simplified)
// Zustand-like immutable store with 50+ fields
const store = createStore({
messages: [], // Conversation history
conversationId: '', // Session identifier
tasks: [], // Background task states
permissionMode: 'default',
isStreaming: false,
// ... 50+ more fields
})

// Subscribe with selectors — surgical re-renders
const messages = useAppState(s => s.messages)
~/.claude/sessions/<id>.json     # Messages + metadata
~/.claude/tasks/<id>.json # Task states
~/.claude/tasks/<id>.output # Task output

Custom Zustand-like store with immutable updates, selector-based subscriptions, and file-based JSON persistence. Resume via claude --resume.

Comparison Matrix

FeatureClaude CodeGoogle ADKOpenAI AgentsLangGraph
State modelZustand-like storeDict + Event listRunState dataclassChannel snapshots
State shapeFixed (50+ fields)Free-form dictTyped dataclassUser-defined schema
Update modelImmutable (spread)Delta bufferingDataclass replaceReducer functions
SubscriptionsSelector hooksNone (pull-based)None (pull-based)None (graph-driven)
Session keysession file pathsession_idsession_idthread_id
Persistence formatJSON filesPluggable (SQLite/Cloud)Pluggable (SQLite/Redis)Pluggable (Postgres/SQLite)
Resume--resume flagEvent replayRunState serializationThread ID reload
Time-travelNoEvent historyNoCheckpoint history
Interrupt/pauseCtrl+C + session saveEnd invocation flagRunState freezeinterrupt() + Command
UI reactivitySelector re-rendersN/A (no UI)N/A (no UI)N/A (no UI)
Key difference

Claude Code is a terminal UI application, so it needs reactive state management (selector-based subscriptions, surgical re-renders) that the other frameworks don't. The others are libraries — they return state to the caller, not render it. Claude Code's custom store is closest to Zustand in the React ecosystem, while the Python frameworks use simpler pull-based state access.

Key Source Files

FilePurpose
src/state/AppState.tsxReact Context Provider
src/state/AppStateStore.tsStore definition and types
src/state/store.tsStore creation
src/context/Specialized contexts (9 files)
src/hooks/State subscription hooks