useAppState / useSetAppState:状态订阅模式
上一篇文章介绍了 AppStateStore 的数据结构和自定义 Store 实现。本篇聚焦于 React 组件如何与这个 Store 交互——特别是 useAppState 和 useSetAppState 这两个 hooks 的设计哲学。
两个 Hook 的核心区别
source/src/state/AppState.tsx 导出了三个主要 hooks:
// 订阅状态切片,状态变化时重渲染
export function useAppState<T>(selector: (state: AppState) => T): T
// 获取 setState 函数,不订阅任何状态,永不因状态变化而重渲染
export function useSetAppState(): (updater: (prev: AppState) => AppState) => void
// 获取完整 store(供需要同时读写且不想订阅的非 React 代码使用)
export function useAppStateStore(): AppStateStore
这是整个状态管理方案中最重要的设计决策:读(订阅)和写(修改)被拆成两个完全独立的 hook。
为什么要分开读写?
性能:不订阅 = 不重渲染
考虑这样一个场景:一个按钮组件,点击时更新全局状态,但自身的显示不依赖任何状态:
// 错误做法:用 useAppState 读取整个状态
function ClearButton() {
const [state, setState] = useAppState(s => s) // 订阅了整个状态!
return (
<Text onPress={() => setState(prev => ({ ...prev, messages: [] }))}>
Clear
</Text>
);
}
// 每次任何状态变化(包括每收到一个 AI token)都会重渲染这个按钮
// 正确做法:只用 useSetAppState
function ClearButton() {
const setAppState = useSetAppState(); // 不订阅任何状态
return (
<Text onPress={() => setAppState(prev => ({ ...prev, messages: [] }))}>
Clear
</Text>
);
}
// 永远不会因状态变化而重渲染,只有父组件重渲染时才会跟着渲染
在流式输出 AI 回复时,AppState 可能每秒更新几十次。如果大量组件订阅了完整状态,会导致严重的性能问题。
稳定引用:useSetAppState 永不变化
useSetAppState 实现非常简单:
export function useSetAppState() {
return useAppStore().setState;
}
Store 的 setState 函数在 createStore 时创建后就固定了——它是一个稳定引用(stable reference)。因此 useSetAppState() 返回的函数引用永远不变,不需要放入 useCallback 的依赖数组。
useAppState:选择器模式详解
export function useAppState<T>(selector: (state: AppState) => T): T {
const store = useAppStore();
const get = () => {
const state = store.getState();
const selected = selector(state);
return selected;
};
return useSyncExternalStore(store.subscribe, get, get);
}
关键在于 useSyncExternalStore——React 18 提供的专门用于订阅外部 Store 的 hook。
useSyncExternalStore 的工作原理
useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)
subscribe:注册监听器,Store 状态变化时调用,React 知道需要检查是否要重渲染getSnapshot:返回当前快照值。React 用Object.is比较前后两次快照:相同则不重渲染,不同则重渲染
整个流程:
Store.setState() 被调用
→ 通知所有 listeners(包括 useSyncExternalStore 注册的 listener)
→ React 调度重渲染检查
→ 调用 getSnapshot()
→ 调用 selector(state) 得到新快照
→ Object.is(oldSnapshot, newSnapshot)?
→ 相同:跳过重渲染(组件不更新)
→ 不同:触发重渲染(组件更新)
选择器的正确写法
重要规则:selector 不能返回新对象。
// 错误:每次调用都返回新对象,Object.is 永远为 false,组件永远重渲染
const messages = useAppState(s => ({
text: s.messages[0]?.text,
count: s.messages.length
}));
// 正确:返回现有的子对象引用
const { text, promptId } = useAppState(s => s.promptSuggestion);
// promptSuggestion 是现有引用,只有当它自身被替换时才触发重渲染
// 正确:返回基础类型值(string/number/boolean)
const verbose = useAppState(s => s.verbose);
const model = useAppState(s => s.mainLoopModel);
源码中有一段被注释掉的调试代码,暴露了这个约束:
// 仅在内部构建中启用("ant" 模式)
if ("external" === 'ant' && state === selected) {
throw new Error(
`Your selector in \`useAppState(${selector.toString()})\` returned the original state...`
);
}
多字段订阅的推荐方式
当需要订阅多个独立字段时,多次调用 hook,而非在一个 selector 中返回聚合对象:
// 推荐:分开调用,每个独立响应自己关心的状态变化
const verbose = useAppState(s => s.verbose);
const model = useAppState(s => s.mainLoopModel);
const tasks = useAppState(s => s.tasks);
// 不推荐:合并成一个对象,任何一个变化都触发重渲染
const { verbose, model, tasks } = useAppState(s => ({
verbose: s.verbose,
model: s.mainLoopModel,
tasks: s.tasks,
}));
AppStateProvider:Store 的生命周期
Store 在 AppStateProvider 中创建,通过 React Context 传递给子组件:
export function AppStateProvider({ children, initialState, onChangeAppState }) {
// useState 的惰性初始化:createStore 只在首次渲染时调用一次
const [store] = useState(() =>
createStore<AppState>(
initialState ?? getDefaultAppState(),
onChangeAppState,
)
);
// 将 store 放入 Context,但 Context 值(store 引用)永远不变
// 所以 Context 变化不会触发任何子组件重渲染
return (
<AppStoreContext.Provider value={store}>
{children}
</AppStoreContext.Provider>
);
}
关键洞察:Context 里存的是 Store 实例(一个稳定对象),而不是状态本身。Store 实例永远不变,所以 Context 的变化不会导致任何重渲染。重渲染完全由 useSyncExternalStore 的 selector 精确控制。
useAppStateMaybeOutsideOfProvider
还有一个特殊版本,用于可能在 AppStateProvider 之外渲染的组件:
export function useAppStateMaybeOutsideOfProvider<T>(
selector: (state: AppState) => T,
): T | undefined {
const store = useContext(AppStoreContext);
return useSyncExternalStore(
store ? store.subscribe : NOOP_SUBSCRIBE,
() => store ? selector(store.getState()) : undefined,
);
}
当 store 为 null(在 Provider 外部)时,NOOP_SUBSCRIBE 永不触发,返回 undefined。这让某些需要兼容两种渲染环境的组件不必关心自己是否在 Provider 内。
反模式:不要在 React 树外直接修改状态
有时候非 React 代码需要访问状态,例如 query.ts 中的 AI 循环。正确做法是通过 useAppStateStore() 获取 Store 引用,然后在需要时调用 store.getState() 或 store.setState():
// 在非 React 的 query.ts 中(通过参数传入 store)
function processToolResult(store: AppStateStore, toolResult: ToolResult) {
// 读取当前状态
const { verbose } = store.getState();
// 更新状态(正确:函数式更新)
store.setState(prev => ({
...prev,
tasks: {
...prev.tasks,
[toolResult.id]: { status: 'complete', result: toolResult }
}
}));
}
小结
useAppState / useSetAppState 的设计体现了一个简洁的原则:订阅范围最小化。
- 只需要读状态的组件:用
useAppState+ 精确的 selector - 只需要写状态的组件:用
useSetAppState,永不重渲染 - 需要同时读写的组件:分别调用两个 hook
这种分离,配合 useSyncExternalStore 的 Object.is 比较机制,让 Claude Code 在处理每秒数十次状态更新时仍然保持流畅。