把「选状态库」理解成选口味,会在渲染粒度和 SSR 水合上立刻失效。Zustand 和 Jotai 都能在 React 外读写,都比「只靠 Context」少一次整树重渲的默认陷阱。真正的差别是状态图的形状:Zustand 是一份(或几份)可切 selector 的中心仓库;Jotai 是一张原子依赖图,派生节点自动订阅上游。数据本来就是一棵领域对象(用户、购物车、编辑器文档),中心仓库更省事。数据本来就是一堆互不相关的开关和格子,原子网更省事。选反了不是性能微调,是每天都在和订阅粒度打架。
这篇文章只讲这张图:Context 为什么常常不是答案、selector 怎么切、原子怎么连、异步该不该进状态库、RSC 之后客户端状态还剩什么。读完你应该能独立回答三个问题:为什么 useStore() 不带 selector 会整页重渲、Jotai 的 Provider 不包为什么 SSR 会串用户、什么时候两边都不要、只把状态放 URL。
1. 先问状态图长什么样
画一张图,节点是会变的数据,边是「谁从谁算出来」。然后问三件事:
- 有几个真源? 购物车是一个对象,还是每个商品行一个独立开关?
- 谁会一起变? 一起变的,放一起订阅;从不一起变的,硬塞一个 store 会让无关组件重渲,或拆成原子却要手动同步。
- 寿命? 页面级、会话级、跨路由、要进 localStorage、要和 URL 同步——寿命不同,不该进同一张图。
状态库解决的是「可变数据的订阅」。服务器来的权威列表,RSC 已经给了 props,不必再抄进 store。抄了就有两个真源:Action 刷新了 props,store 还是旧的。乐观更新那篇说过,权威在 server props。客户端 store 只放:还没上服务器的交互态(草稿、选中 id、面板开合)、以及跨路由要活着且不属于 URL 的会话态。
URL 能表达的(tab、分页、筛选)放 URL。刷新、分享、后退免费得到。再做一份 store 镜像,是第三真源。
2. Context 为什么常常不是答案
useState + Context 能传值。默认行为是:value 引用变了,所有 useContext 的消费者重渲。把整份 app state 放进一个 Context,等于没有 selector。拆很多 Context 等于手写一张订阅图,还没有 Devtools。
useSyncExternalStore 是 React 18 给外部 store 的合法入口:订阅、快照、getServerSnapshot。Zustand / Jotai 内部走它。自己用 Context「优化」到最后,会重新发明这两个库的一半,而且没有另一半(中间件、原子组合)。
Context 的正当位置:主题、i18n、DI(把 store 本身注入,而不是注入 state 值)。注入 store,消费者用 selector 读字段。注入 value 对象,你已经输了粒度。
3. Zustand:一个 store,切 selector
create 返回 hook。不带参数等于订阅整个 state;带函数等于只订阅切片。切片引用变了才重渲。
import { create } from "zustand";
type CartItem = { id: string; qty: number };
type CartState = {
items: CartItem[];
add: (id: string) => void;
setQty: (id: string, qty: number) => void;
};
export const useCart = create<CartState>()((set, get) => ({
items: [],
add: (id) => {
const cur = get().items;
const i = cur.findIndex((x) => x.id === id);
if (i === -1) set({ items: [...cur, { id, qty: 1 }] });
else set({ items: cur.map((x, j) => (j === i ? { ...x, qty: x.qty + 1 } : x)) });
},
setQty: (id, qty) => set({ items: get().items.map((x) => (x.id === id ? { ...x, qty } : x)) }),
}));
组件:
const items = useCart((s) => s.items);
const add = useCart((s) => s.add);
add 函数引用稳定(默认),items 只有数组换新引用时重渲。useCart((s) => ({ items: s.items, add: s.add })) 每次返回新对象,每次都重渲。要返回对象,用 shallow 比较,或拆成两次 hook。这是 Zustand 第一坑,比选库争论常见十倍。
set 是浅合并。set({ items }) 不动其它字段。嵌套对象要自己不可变更新。没有 Immer 就老老实实展开;用 immer 中间件可以 set((s) => { s.items.push(...) }),但那是中间件,不是默认。
多个 store 合法。用户会话一个,编辑器文档一个。不要做 useAppStore 上帝对象——那是 Redux 早期单体再加一个 hook。selector 再细,任意字段变化仍可能碰到「谁不小心订阅了根」。拆 store 按变化率和寿命,不按文件数量。
4. 中间件、Devtools、不可变
Zustand 中间件是函数包装 create。devtools、persist、subscribeWithSelector 常见。persist 把切片写 localStorage,SSR 时服务器没有 localStorage,水合会对不齐。处理:skipHydration + 客户端 useEffect 里 rehydrate,或接受第一帧用默认值。不要在模块顶层读 localStorage 当 initial——那会在服务器和浏览器各一份,水合警告,严重时串出「服务器 HTML 带用户 A 的草稿」。
import { create } from "zustand";
import { persist } from "zustand/middleware";
export const useDraft = create<{ text: string; setText: (t: string) => void }>()(
persist(
(set) => ({
text: "",
setText: (text) => set({ text }),
}),
{ name: "draft", skipHydration: true },
),
);
订阅粒度:subscribeWithSelector 让 store.subscribe(selector, listener) 只在切片变时响。React 外的代码(非 React 模块、WebSocket handler)用 useCart.getState() / useCart.setState(),不要为了改一行去 createRoot。这是 Zustand 相对 Context 的本质优势:store 先于 UI 存在。
5. Jotai:原子和依赖收集
一个 atom 是一个节点。组件 useAtom(x) 只订阅 x。派生 atom 在读上游时自动登记依赖,上游变了才重算。
import { atom } from "jotai";
export const countAtom = atom(0);
export const doubleAtom = atom((get) => get(countAtom) * 2);
export const incAtom = atom(null, (get, set) => set(countAtom, get(countAtom) + 1));
写 atom(第三种)把命令收成节点,组件 useSetAtom(incAtom) 不订阅 count,点按钮不因 count 变而重渲——按钮本就不必重渲。这是原子图的细粒度:读和写可以分开订。
Provider 给一张独立的原子存储。SSR 时每个请求一个 Provider,否则默认存储是模块单例,用户 A 的 count 会漏到用户 B。这是 Jotai 第一坑,比 Zustand 更狠,因为 atom 定义在模块顶层,看起来像「没有全局状态」。没有 Provider,就是全局。
import { Provider } from "jotai";
export function AppProviders({ children }: { children: React.ReactNode }) {
return <Provider>{children}</Provider>;
}
原子过多时,心智负担从「store 太大」变成「谁依赖谁」。一张 200 节点的图没有文档,和上帝 store 一样不可维护。原子要有边界:按功能文件夹导出一组 atom,不要从 atoms.ts 倒出 80 个。
6. 异步:状态库不是查询库
Zustand 里写 fetchUsers: async () => { const r = await api(); set({ users: r }) } 能跑。并发、缓存、重试、失效、去重,你都要自己做。Jotai 的 async atom 能 await get(otherAtom),Suspense 友好,但仍然不是规范化缓存。
列表、详情、分页、失效标签,用 TanStack Query(或 SWR)。状态库放:当前选中 id、草稿、UI 开关。Query 放:服务器数据。选中 id 变了,queryKey 变了,query 去拉。不要把服务器列表抄进 Zustand 再在 Action 后手动同步——你在实现一个更差的 query cache。
import { atom } from "jotai";
export const selectedIdAtom = atom<string | null>(null);
const id = useAtomValue(selectedIdAtom);
const { data } = useQuery({ queryKey: ["product", id], queryFn: () => getProduct(id!), enabled: !!id });
异步 atom 适合「派生过程本身是异步,且寿命跟组件树走」的局部计算,不适合当 HTTP 缓存。错误、loading、stale 这些状态 Query 已经建模。再造一份 { loading, error, data } 在 store 里,是 2018 年的写法。
7. SSR 水合:两份快照必须同一份
useSyncExternalStore 要求服务器快照和客户端第一帧快照一致。不一致:水合 mismatch,React 会丢掉服务器 HTML。表现是闪一下、输入丢了、警告刷屏。
规则:
- 服务器不要读
window/localStorage/document.cookie来初始化 store(cookie 在 RSC 里用cookies()读,作为 props 传下来,不要当 store 初始值的隐式输入)。 - 需要浏览器存储的状态,第一帧用默认值,mount 后再 hydrate。接受第一帧无草稿。
- Jotai:请求级
Provider。Zustand:不要模块单例里塞用户数据;用户数据从 RSC props 来,或显式createStore()每请求一份。
import { createStore } from "zustand";
export function createCartStore(init?: Partial<CartState>) {
return createStore<CartState>()((set, get) => ({
items: init?.items ?? [],
add: (id) => { /* 同前 */ },
setQty: (id, qty) => { /* 同前 */ },
}));
}
每请求 createCartStore(),用 Context 把 store 传下去。模块级 useCart 单例只适合没有用户私有数据的真正全局 UI(如「侧栏开合」,且你接受 SSR 永远默认闭合)。
8. 和 RSC 的分工
Server Component 不能用 useState / Zustand hook。它能读 cookie、能 await 数据库、能把结果当 props 传给客户端叶子。叶子里才出现 store。
合法:RSC 拉购物车快照 → 客户端 useCart 以快照为初始 → 本地加减 → Action 提交 → revalidatePath → RSC 再传新快照。非法:RSC 和客户端各 fetch 一次购物车;或 store 在模块单例里活过导航,RSC 新快照来了 store 不理。
导航时 App Router 保活 layout。layout 里的客户端 store 不会因为 page 换了而重置。这是「跨页购物车还在」的原因,也是「从用户 A 切到登录页再进用户 B,store 还是 A」的原因。登出 Action 必须显式 store.setState(initial),不要指望卸载。layout 不卸载。
9. 取舍
| Zustand | Jotai | |
|---|---|---|
| 图 | 中心对象 | 原子 DAG |
| 订阅 | 你写 selector | 依赖收集 |
| 心智 | Redux 简化 | Recoil / 电子表格 |
| SSR | 单例易踩私有数据 | 必须 Provider |
| 工具 | Devtools 时间旅行熟 | 图可视化看依赖 |
| 适合 | 领域对象、编辑器文档 | 大量离散状态、派生多 |
团队从 Redux 来、有清晰的聚合根 → Zustand。页面是格子、筛选器、每个格子独立 → Jotai。同一功能里不要两个真源。可以在应用级 Zustand 放会话,在某个复杂页局部用 Jotai——但要画清楚边界,禁止两边互相同步同一字段。
都不适合:表单的瞬时输入(useState)、服务器列表(Query)、能放 URL 的筛选(searchParams)。
10. 排障
- 无关组件跟着跳。 Zustand 没 selector,或 selector 返回新对象。Jotai 在组件里
useAtom了过大的根原子。画订阅。 - 水合警告。 第一帧读了
localStorage。推迟 hydrate。 - 串用户。 Jotai 没 Provider;Zustand 模块单例塞了用户数据。
- 登出后还在。 store 在 layout,没重置。
- 和 Query 打架。 列表进了 store。删掉,只留 selectedId。
- 乐观对不上。 store 当了权威。权威回 props。
- Devtools 里 action 名叫
anonymous。set第三参给名字;或用devtools中间件。没法复盘的 store 等于没法排障。 - 测试里状态泄漏。 单例 store 跨测试。每个测试
setState(initial)或用createStore。
11. 什么时候两边都不要
默认路径:RSC 给权威 → 客户端 useState 给叶子交互 → 要跨叶子再提升 → 提升到 URL 或提升到一个小 store。跳过前两步直接上全局库,是过早的中心化。
编辑器、画布、复杂表格,状态更新频率高、对象大,Context 和原子的每节点 hook 开销都可能成为问题。这时 Zustand 一个 store + 细 selector,或把热路径放进 useRef + 订阅(不经 React 渲染)是正当的。先量:React Profiler 看是不是这条状态链路。不要因为文章标题有 Jotai 就把 K 线图拆成一万个 atom。
WebSocket 进来的数据:handler 里 setState,不要每条消息 useState。节流到动画帧。状态库在这里是外部事件的汇合点,这是 useSyncExternalStore 的本职。
12. 小结
状态库的原理是订阅粒度。Zustand 用中心仓库加 selector 买明确的领域对象;Jotai 用原子 DAG 买自动依赖和细订阅。Context 传值不传订阅。服务器数据进 Query,交互草稿进 store,可分享的进 URL,权威进 RSC props。SSR 要请求级存储,layout 保活要显式重置。选图的形状,不要选logo。下一篇把「快」从状态更新换成用户时间:LCP、INP、CLS 是三条不同的等待,对策不能共用一个「减包体积」。
7. 深入 React useSyncExternalStore 原生订阅环与并发撕裂 (Tearing) 防御
现代状态管理库(无论是集中式的 Zustand 还是原子化的 Jotai)之所以能完美融入 React 18/19 的并发渲染,全赖于底层统一桥接了官方的 useSyncExternalStore 原生 Hook。
7.1 什么是状态撕裂(State Tearing)?
在 Concurrent Mode 下,React 允许高优先级的用户输入打断正在进行中的低优先级渲染。如果状态存储在 React 外部(如全局 JavaScript 变量):
- 树的上半部分读取了旧版本数据
v1; - 此时外部状态被异步修改为
v2; - 恢复渲染后,树的下半部分读取了新数据
v2; - 最终呈现给用户的 UI 出现数据不一致(上显示旧值,下显示新值),这就是灾难性的“状态撕裂”。
7.2 useSyncExternalStore 的原子保护机制
Zustand 的内部核心实现精简至极:
// Zustand 核心调度简化源码
import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector';
export function useStore(api, selector = identity, equalityFn = Object.is) {
return useSyncExternalStoreWithSelector(
api.subscribe, // 状态变更时的监听注册器
api.getState, // 客户端同步读取状态快照的纯函数
api.getInitialState, // 服务端渲染脱水状态快照
selector, // 局部数据选择器
equalityFn // 脏检查浅比对函数
);
}
深度原理:React 运行时在每次并发工作单元结束时,会自动调用 getState 检查版本一致性。一旦发现数据在渲染期间发生外部突变,React 会立即废弃当前所有临时中间树,自动以同步最高优先级从根节点重新渲染,从数学层面彻底消除了状态撕裂的发生可能。
8. 内存泄漏终结者:Jotai 派生原子的 WeakMap 垃圾回收机制
在许多拥有海量动态列表(如长列表树节点、画布图元)的应用中,每个元素都会动态创建对应的原子:
const elementAtomFamily = atomFamily((id: string) => atom({ id, selected: false }));
如果使用传统 Map 缓存这些原子,被删除节点的原子将永远驻留内存导致 OOM。 Jotai 内部通过将其与 WeakMap / WeakRef 深度绑定,当 DOM 节点卸载且无任何组件订阅该原子时,JavaScript V8 垃圾回收器能够无障碍回收对应的原子实体与中间计算缓存,为超大型画布与复杂表单应用提供了极为稳固的内存底座。