状态管理:没有 Redux 的 React 状态架构
深入 Claude Code 的极简状态管理——不到 35 行的自定义 Store、Object.is 变更检测、细粒度订阅
问题引入
React 状态管理是前端工程中永恒的话题。从 Redux 到 MobX,从 Zustand 到 Jotai,状态管理库层出不穷。然而 Claude Code 选择了一条令人意外的路径——用不到 35 行代码实现了一个完整的状态管理系统,却没有引入任何第三方库。
这不是玩具代码。Claude Code 的 AppState 包含超过 80 个字段,涵盖设置、MCP 连接、插件、权限、桥接状态、团队协作等维度。一个 35 行的 Store 如何撑起这么庞大的状态树?为什么它选择不用 Redux?这背后是怎样的权衡取舍?
Store 的完整实现
先看代码。这是 src/state/store.ts 的全部内容:
34 行。没有 middleware、没有 devtools、没有 immer。让我们逐层剖析为什么这已经足够。
架构总览
三个核心 API
getState()— 同步获取当前状态快照,零开销。setState(updater)— 接受一个纯函数(prev) => next。如果Object.is(next, prev)为 true,什么都不做。subscribe(listener)— 注册一个无参数回调,返回取消订阅函数。
这与 useSyncExternalStore 的契约完美匹配——React 18 专门为这种外部 Store 设计了这个 Hook。
Object.is 变更检测:一行代码的深意
这一行看似简单,实则意义深远。
Object.is 执行的是引用相等比较——如果 updater 返回同一个对象引用,就认为状态没有变化。这意味着:
- 不可变更新是强制的。如果你想改变状态,必须返回一个新对象:
prev => ({ ...prev, verbose: true })。 - 无变化时零成本。如果 updater 内部判断不需要更新,可以直接
return prev,Store 不会触发任何通知。 - 没有深比较开销。Redux 的
shallowEqual、Zustand 的Object.isselector 对比——Claude Code 把这个检查放在了最上层。
来看一个实际的优化案例,src/state/teammateViewHelpers.ts 中的 enterTeammateView:
第 66 行的 if (!needsRetain && !needsView && !switching) return prev 是一个常见模式——在 updater 内部做条件判断,避免不必要的状态更新。由于 Object.is 检查,返回 prev 意味着零副作用。
React Context 与 useSyncExternalStore
Store 的消费侧在 src/state/AppState.tsx 中实现。
Provider 层
关键设计:
- Store 在
useState惰性初始化中创建,确保整个应用生命周期只有一个 Store 实例。 - 嵌套检测 —
HasAppStateContext防止意外创建多个 Provider。 onChangeAppState回调 — 在创建时注入,用于状态变更的副作用。useSettingsChange— 监听配置文件的外部变更(文件系统、环境变量),将其注入 Store。
useAppState Hook
这是整个状态管理系统的核心消费 API。useSyncExternalStore 是 React 18 引入的底层 Hook,它接受三个参数:
subscribe— 注册变更通知getSnapshot— 获取当前值getServerSnapshot— SSR 快照(这里复用 getSnapshot)
当 Store 发出通知时,React 调用 getSnapshot 获取新值,与上次通过 Object.is 比较。如果值相同,跳过渲染;如果不同,触发组件重渲染。
这带来了细粒度更新能力:
数据流完整路径
onChangeAppState:状态变更的副作用层
src/state/onChangeAppState.ts 是 Store 的 onChange 回调实现。这是整个系统中唯一集中处理状态变更副作用的地方。
这个模式的精妙之处在于关注点分离:
- Store 本身不知道任何副作用逻辑。
onChangeAppState作为一个纯粹的diff-handler,只在状态实际变化时才执行。- 每个副作用块都独立——permission mode 同步、模型持久化、配置缓存清理,互不干扰。
对比 Redux 的 middleware 模式,这里没有 action type 字符串、没有 dispatch 链、没有 saga/thunk。直接比较 oldState.x !== newState.x,清晰无歧义。
AppState 的结构设计
来看 src/state/AppStateStore.ts 中定义的 AppState 类型。它是一个约 450 行的庞大类型定义:
注意 DeepImmutable<...> & { ... } 的结构:
- DeepImmutable 部分 — 简单值类型字段,TypeScript 编译器保证不可变。
- 非 DeepImmutable 部分 — 包含函数类型(如
AbortController)或特殊集合(如Map,Set)的字段,手动管理不可变性。
默认状态工厂
注意默认值不是硬编码常量——getInitialSettings() 从配置系统读取合并后的设置,shouldEnableThinkingByDefault() 根据环境决定是否启用 thinking mode。这意味着 Store 的初始状态本身就是动态计算的。
Selectors 模式
src/state/selectors.ts 展示了如何从 AppState 派生计算值:
Selector 使用 Pick<AppState, ...> 明确声明依赖——这不仅是类型安全,更是文档。你一眼就能看出 getViewedTeammateTask 只依赖两个字段。
与 Redux/Zustand 的对比
| 特性 | Redux | Zustand | Claude Code |
|---|---|---|---|
| 代码量 | ~2000 行核心 | ~400 行核心 | 34 行 |
| Action 类型 | 字符串常量 | 不需要 | 不需要 |
| Middleware | 链式 | 链式 | onChange 回调 |
| 不可变性 | 手动 / Immer | 可选 Immer | 手动 + TypeScript |
| DevTools | 内置 | 内置 | 无(不需要) |
| 变更检测 | shallowEqual | Object.is | Object.is |
| React 集成 | connect/useSelector | useStore | useSyncExternalStore |
| 副作用 | saga/thunk | middleware | onChangeAppState |
| 依赖 | react-redux | zustand | 零依赖 |
为什么不用 Redux?
- CLI 应用没有 undo/redo 需求 — Redux 的 action log 在 CLI 中没有实际价值。
- 没有多 Store 交互 — Claude Code 只有一个全局 Store。
- 没有复杂异步流 — 不需要 saga 的生成器或 thunk 的嵌套 dispatch。
- 启动性能 — 35 行的 Store 不需要加载任何外部依赖。
为什么不用 Zustand?
Zustand 实际上非常接近 Claude Code 的设计。但仔细对比会发现:
- Zustand 的
set接受 partial state — Claude Code 强制使用prev => next函数式更新,避免意外覆盖。 - Zustand 的
subscribe带 selector — Claude Code 把 selector 放在useSyncExternalStore层,更接近 React 的原生模型。 - 零依赖 — Claude Code 的 Store 不引入任何包。对于 CLI 应用的启动速度,每少一个依赖就少一次模块加载。
设置变更的热更新
AppStateProvider 中的 useSettingsChange 监听配置文件变化:
当用户在另一个终端编辑 ~/.claude/settings.json,或者企业管理员推送远程配置时:
- 文件系统监听器检测到变化
useSettingsChange触发回调applySettingsChange构造新的 settings 对象store.setState更新状态onChangeAppState检测到newState.settings !== oldState.settings- 清除认证缓存、重新应用环境变量
整个链路不需要手动调度任何事件——从文件变更到 UI 更新,完全自动。
DCE 与条件 Provider
AppState.tsx 中有一个 feature flag 控制的条件加载:
当 VOICE_MODE feature flag 关闭时,VoiceProvider 被替换为一个直通组件。Bun 的编译器会在打包时将 feature('VOICE_MODE') 替换为 false,然后通过死代码消除,整个 require('../context/voice.js') 都不会出现在最终产物中。
这意味着 AppStateProvider 的 Provider 包装层是可变的——根据编译配置,它可以包含或排除 Voice、Mailbox 等上下文。
性能特性
批量更新
React 18 默认启用自动批量更新。多个 setState 调用在同一个事件循环 tick 内只会触发一次重渲染。Claude Code 的 Store 天然兼容这个机制——subscribe 的 listener 触发后,React 的 scheduler 会合并渲染。
选择性订阅
useAppState 的 JSDoc 明确警告不要返回整个状态对象。源码中甚至有一个(开发模式下启用的)运行时检查:
if (false && ...) 意味着这个检查在生产构建中被完全消除,但开发时可以通过修改条件来启用。
无新对象规则
useAppState 的文档强调:
Do NOT return new objects from the selector -- Object.is will always see them as changed.
(译:不要在 selector 中返回新对象——Object.is 会始终认为它们发生了变化。)
这个约束来自 useSyncExternalStore 的工作原理——每次 Store 发出通知,React 都会调用 getSnapshot 并通过 Object.is 与上次返回值比较。如果 selector 返回新对象,Object.is 永远为 false,导致无限重渲染。
实战:状态更新的完整路径
以切换 verbose 模式为例,追踪完整路径:
-
用户操作:在设置中切换 verbose
-
setState 调用:
-
Store 内部:
Object.is(next, prev)→ false(新对象)- 更新内部
state引用 - 调用
onChange({ newState: next, oldState: prev }) - 遍历
listeners通知
-
onChangeAppState:
-
React 更新:
useSyncExternalStore收到通知- 调用
selector(store.getState())获取新的verbose值 Object.is(true, false)→ false → 触发重渲染
-
UI 更新:组件使用新的 verbose 值渲染
整个过程没有 action 类型字符串、没有 reducer switch-case、没有 middleware 管道。一个 setState 调用完成所有工作。
总结
Claude Code 的状态管理是一个极简主义的胜利:
- 34 行核心代码 —
createStore提供完整的订阅/更新/检测功能 - 零外部依赖 — 不引入 Redux、Zustand、MobX 中的任何一个
Object.is短路 — 在最早的时机拦截无效更新useSyncExternalStore集成 — 利用 React 18 原生 API 实现细粒度订阅onChangeAppState集中副作用 — 取代 middleware,在一个函数中处理所有状态同步
不是每个项目都需要 Redux。当你的应用只有一个 Store、不需要时间旅行调试、不需要复杂异步流时,34 行代码就是最好的状态管理库。