状态管理机制:Claude Code 如何组织运行时状态
这篇文档只回答一个问题:
src/ 里的运行时状态,到底是怎样被定义、初始化、订阅、更新并扩散副作用的?
先给结论:
- 这套实现不是 Redux/Zustand 一类第三方状态库方案
- 也不是纯 React
useState/useReducer驱动的组件内状态方案 - 它的核心模式是:自研外部 store + Context 注入 +
useSyncExternalStore切片订阅 - 在整体上,项目采用的是:一个主
AppState+ 多个领域专用 store / 轻量 Context 的组合式架构
可以先把主链路记成下面这条:
初始化默认状态
-> 创建 store
-> Provider 注入 / Headless 直连
-> 组件按 selector 订阅
-> setState 更新
-> onChangeAppState 扩散副作用
1. 先判断:这不是“单一状态容器包打天下”
如果只看目录,很容易以为这个项目会使用 Redux、Zustand、MobX 之类的现成方案,但实际源码不是这样。
围绕状态管理的核心文件主要有:
src/state/store.tssrc/state/AppState.tsxsrc/state/AppStateStore.tssrc/state/onChangeAppState.tssrc/components/App.tsxsrc/main.tsx
它们共同构成了一个很明确的分层:
store.ts提供最小外部 store 原语AppStateStore.ts定义全局状态结构和默认值AppState.tsx把 store 接到 React 树里,并提供读写 hookonChangeAppState.ts负责状态变化后的副作用扩散- 其他领域再按需要选择:
- 进入主
AppState - 自己维护专用 store
- 或只用一个轻量 Context 做局部依赖注入
- 进入主
这意味着它并不追求“所有状态都进一个 reducer”,而是追求:
- 全局会话态统一
- 局部领域态按需隔离
- React 组件和非 React 代码都能共享同一份状态心智模型
2. 第 1 层:最底层的 store 原语
src/state/store.ts 是整个状态体系最底层的基石。它没有引入任何复杂框架,只定义了一个很小的 store 接口:
getState()setState(updater)subscribe(listener)
这份实现有几个特点:
2.1 setState 是同步的
调用 setState 时,会立刻:
- 读取旧状态
prev - 用 updater 计算新状态
next - 如果
Object.is(next, prev)为真就直接跳过 - 否则写入新状态并通知订阅者
这让它既能服务 React,也能服务命令式逻辑。
很多异步流程会在更新后立刻调用 getState() 读取最新值,而不用等待下一轮渲染。
2.2 它是“外部 store”,不是组件 state
这里的状态不挂在某个组件实例上,而是一个独立对象。
这正是后面可以接 useSyncExternalStore 的原因。
2.3 它自带 onChange
createStore(initialState, onChange?) 支持在每次状态变化后执行回调。
这个能力后面会被 AppState 用来接 onChangeAppState,形成“状态更新”和“副作用扩散”分离的结构。
3. 第 2 层:全局应用状态 AppState
如果说 store.ts 提供的是“状态引擎”,那么 AppState 就是这个项目的主状态平面。
3.1 AppStateStore.ts 是全局 schema 中心
src/state/AppStateStore.ts 负责两件事:
- 定义
AppState的完整类型 - 提供
getDefaultAppState()作为默认状态装配点
这里可以把它理解成“全局运行时状态表”。
状态域非常多,但典型的核心区域包括:
tasks:后台任务、子代理、远程任务等统一任务状态mcp:MCP clients / tools / commands / resourcesplugins:插件启用状态、命令、错误、安装状态notifications:当前通知与通知队列toolPermissionContext:工具权限模式和权限上下文promptSuggestion:Prompt 建议的展示与接受状态teamContext:swarm / teammate 的团队上下文
除此之外,还有文件历史、bridge 状态、远程会话状态、overlay 集合、plan mode 相关状态等。
这说明 AppState 不是 UI 小状态集合,而是整个会话 runtime 的控制平面。
3.2 默认状态从 getDefaultAppState() 开始
getDefaultAppState() 负责初始化整个会话的默认值,例如:
- 空的
tasks - 空的
mcp.clients/tools/commands/resources - 初始
toolPermissionContext - 初始
notifications - 各种 bridge / remote / prompt suggestion / overlay 的默认状态
这里不是简单返回一个“空对象”,而是把运行时真正需要的初始结构一次性装配好。
后面无论交互态还是无头态,都是从这份默认结构继续扩展。
4. 第 3 层:React 如何消费这份全局状态
src/state/AppState.tsx 负责把外部 store 接进 React。
4.1 AppStateProvider 做了什么
AppStateProvider 的核心逻辑很直接:
- 用
createStore(initialState ?? getDefaultAppState(), onChangeAppState)创建 store - 用
AppStoreContext把 store 注入到 React 树 - 暴露给子组件统一消费
它还做了两件补充工作:
- 挂接 settings 变化监听,把外部 settings 文件变化同步回
AppState - 在 provider 内层再包
MailboxProvider和可选VoiceProvider
一个重要细节是:store 在 provider 生命周期里只创建一次。
这让 context value 保持稳定,provider 本身不会因为每次状态更新而触发整棵树重渲染。
4.2 useAppState 是“按切片订阅”
useAppState(selector) 内部用的是:
store.getState()读取快照useSyncExternalStore(store.subscribe, get, get)建立订阅
它的语义不是“拿整个 state”,而是“订阅某个切片”。
例如:
const verbose = useAppState(s => s.verbose)
const permissionMode = useAppState(s => s.toolPermissionContext.mode)
这样组件只会在选中的值变化时重渲染,而不会因为其他字段变化被动刷新。
4.3 useSetAppState 是“只写不订阅”
useSetAppState() 直接返回 store.setState。
这意味着:
- 调用方可以更新全局状态
- 但不会因为状态变化而重渲染
这种模式特别适合:
- 命令按钮
- 事件处理器
- 只负责发起状态变更的 hooks
4.4 useAppStateStore 是给非 React / 异步逻辑的命令式入口
useAppStateStore() 直接返回整个 store。
这样调用方就能使用:
store.getState()store.setState()
这在很多异步流程里很关键,因为它可以避免 stale closure。
典型场景是:
useCancelRequest.tsuseInboxPoller.tsuseTypeahead.tsxuseReplBridge.tsx
这些逻辑经常需要在定时器、异步回调或事件处理中读取“此刻最新”的状态,而不是依赖某次渲染时捕获的旧快照。
4.5 交互态和无头态共享同一套 store 心智模型
交互态入口在 src/components/App.tsx:
- 外层挂
FpsMetricsProvider - 再挂
StatsProvider - 再挂
AppStateProvider initialState={initialState} onChangeAppState={onChangeAppState}
无头态则在 src/main.tsx 里直接创建:
const headlessStore = createStore(headlessInitialState, onChangeAppState)
这说明两种模式虽然 UI 不同,但底层状态组织方式是一致的:
- 都从
AppState出发 - 都使用同一个 store 原语
- 都复用同一个
onChangeAppState
也就是说,项目并没有把“交互式 UI 状态”和“headless/SDK 状态”拆成两套完全不同的机制。
5. 第 4 层:状态更新之后,副作用往哪里走
src/state/onChangeAppState.ts 是理解这套系统的关键文件。
它不是 reducer,而是“状态变化后的副作用汇聚点”。
换句话说:
setState只负责把状态从 A 变到 BonChangeAppState负责观察oldState -> newState的变化,并把需要的副作用同步出去
5.1 它负责哪些同步
从当前实现看,主要包括这几类:
- 权限模式变化:向 CCR / SDK 状态通道同步 permission mode
- 模型配置变化:把
mainLoopModel写回 settings / bootstrap override - 全局配置持久化:例如
expandedView、verbose、tungstenPanelVisible - settings 变化后的刷新:清理 auth 相关 cache,并重新应用环境变量
这带来一个很重要的好处:
- 状态更新逻辑可以尽量保持纯粹
- 副作用不必散落在每个
setState调用点
对于大型项目来说,这种“单一副作用出口”比到处手写同步逻辑更稳。
6. 为什么要避免 selector 返回新对象
useAppState 订阅切片时,比较依据是 Object.is。
因此它天然偏向“引用稳定”的子对象或标量值。
这也是 AppState.tsx 里明确强调的约束:
- 不要让 selector 直接返回新对象
- 要尽量选择已有引用
例如推荐:
const promptSuggestion = useAppState(s => s.promptSuggestion)
而不是:
const value = useAppState(s => ({ text: s.promptSuggestion.text }))
原因很简单:后一种写法每次都会生成新对象,Object.is 一定判定为变化,订阅优化就失效了。
7. 不可变更新约定:为什么 Set / Map 要新建引用
这套状态体系默认遵循不可变更新约定。
也就是说,更新时通常会:
- 返回新的顶层对象
- 对被修改的子对象创建新引用
这一点在普通对象上很好理解,在 Set / Map 上尤其重要。
例如:
AppState里有agentNameRegistry: Map<string, AgentId>activeOverlays: ReadonlySet<string>
如果直接原地修改这些结构,外层引用不变,订阅方就可能感知不到变化。
所以像 src/context/overlayContext.tsx 这类代码会显式:
- 用旧的
Set构造一个新的Set - 在新引用上
add/delete - 再把新引用写回 state
这和 React 社区常见的不可变更新原则是一致的,只是这里不依赖 Immer 之类工具,而是手工维持引用边界。
8. 局部专用 store:不是所有状态都进 AppState
这个仓库没有把所有状态都塞进全局 AppState,而是明确保留了一些领域专用状态容器。
这不是分裂,而是分层。
8.1 src/context/voice.tsx:局部专用 store
voice.tsx 的实现几乎就是 AppState 模式的缩小版:
- 仍然用
createStore(DEFAULT_STATE) - 仍然用 Context 注入 store
- 仍然用
useSyncExternalStore做切片订阅
它提供了:
useVoiceState(selector):响应式读取useSetVoiceState():只写不订阅useGetVoiceState():命令式读取最新语音状态
之所以不把这些字段直接并入 AppState,是因为它只服务语音域,独立维护更清晰,也避免让全局状态继续膨胀。
8.2 src/context/stats.tsx:指标收集 store
stats.tsx 维护的是另一种完全不同的状态:
- counter
- gauge
- histogram
- set
它的目标不是驱动 UI 响应式刷新,而是收集指标,并在进程退出时 flush 到配置中。
因此它虽然也是一个 store,但职责更像 telemetry accumulator,而不是应用会话状态中心。
8.3 src/hooks/useTasksV2.ts:模块级单例 store
useTasksV2.ts 更有代表性。
它既不走 AppState,也不是简单 Context,而是一个模块级单例 store:
- 内部维护
TasksV2Store - 用
fs.watch、fallback poll、debounce timer 管理任务列表 - 用
useSyncExternalStore暴露快照
这么做的直接收益是:
- 多个组件共享同一份 watcher
- 避免 spinner / footer / REPL 分别监听同一目录
- 让“文件系统驱动的状态”独立于主
AppState
这类状态更适合“模块自管理 + React 订阅”,而不是硬塞进全局 store。
9. 轻量 Context:只做局部依赖注入,不做全局状态总线
还有一类状态更轻,只需要局部作用域,不值得进 AppState,也不需要专门 store。
9.1 src/context/modalContext.tsx
ModalContext 提供的是 modal 内可用的:
rowscolumnsscrollRef
这是纯布局上下文。
它解决的是“组件当前是否在 modal slot 内渲染、可用空间多大”的问题,不属于会话全局状态。
9.2 src/context/mailbox.tsx
MailboxProvider 只是创建一个稳定的 Mailbox 实例并通过 Context 分发。
这里的重点是共享一个对象实例,而不是建立一个可订阅的全局状态树。
9.3 src/context/promptOverlayContext.tsx
这个 Context 更像 prompt 上层浮层的 portal 协调器。
它把状态拆成:
- 数据 Context
- setter Context
- dialog Context
- dialog setter Context
这样写的目的不是做复杂状态管理,而是:
- 让读取者拿到当前 overlay 数据
- 让写入者拿到稳定 setter
- 避免写入者因为自己的写入再次重渲染
这是一种非常典型的“局部 UI 协调 Context”用法。
10. selectors.ts 说明:项目允许“派生读取”,但保持简单
src/state/selectors.ts 里已经开始出现一些显式 selector,例如:
getViewedTeammateTask(...)getActiveAgentForInput(...)
这说明项目并不反对派生状态,但它对 selector 的要求很明确:
- 保持纯函数
- 只做数据提取和轻量判断
- 不承担副作用
因此这套体系更接近“轻量 selector + 外部 store + hook 订阅”,而不是完整的 Redux selector 生态。
11. 回答三个最核心的问题
11.1 全局状态存在哪里
全局状态定义在 src/state/AppStateStore.ts 的 AppState 中,运行时 store 由 src/state/AppState.tsx 或 src/main.tsx 基于 createStore(...) 创建。
11.2 组件和非组件代码分别怎么读写状态
组件里主要用:
useAppState(selector):读切片useSetAppState():写状态
非组件或异步逻辑里主要用:
useAppStateStore()拿到 store- 再通过
store.getState()/store.setState()命令式读写
11.3 哪些状态为什么没有放进 AppState
主要有三类:
- 领域专用 store:如
voice.tsx,只服务单个能力域 - 模块级单例 store:如
useTasksV2.ts,更适合自己管理 watcher / timer / snapshot - 轻量 Context:如
modalContext.tsx、mailbox.tsx、promptOverlayContext.tsx,只做局部依赖注入,不需要全局总线
12. 最后的架构判断
把这套实现压缩成一句话,就是:
Claude Code 的状态管理不是“一个框架接管全部状态”,而是“用最小外部 store 承担全局会话状态,再用专用 store 和轻量 Context 承担局部领域状态”。
这种设计的实际收益是:
- 组件订阅粒度细
- 非 React 逻辑也能稳定读写状态
- 副作用出口集中
- 大量局部状态不必挤进全局容器
对应的代价也很明显:
- 需要团队自觉维护不可变更新约定
- 需要手动控制 selector 粒度与引用稳定性
- 需要清楚区分“该进
AppState”和“该局部隔离”的边界
但从当前仓库实现看,这种取舍是明确而一致的。