把「选状态库」理解成选口味,会在渲染粒度和 SSR 水合上立刻失效。Zustand 和 Jotai 都能在 React 外读写,都比「只靠 Context」少一次整树重渲的默认陷阱。真正的差别是状态图的形状:Zustand 是一份(或几份)可切 selector 的中心仓库;Jotai 是一张原子依赖图,派生节点自动订阅上游。数据本来就是一棵领域对象(用户、购物车、编辑器文档),中心仓库更省事。数据本来就是一堆互不相关的开关和格子,原子网更省事。选反了不是性能微调,是每天都在和订阅粒度打架。

这篇文章只讲这张图:Context 为什么常常不是答案、selector 怎么切、原子怎么连、异步该不该进状态库、RSC 之后客户端状态还剩什么。读完你应该能独立回答三个问题:为什么 useStore() 不带 selector 会整页重渲、Jotai 的 Provider 不包为什么 SSR 会串用户、什么时候两边都不要、只把状态放 URL。

1. 先问状态图长什么样

画一张图,节点是会变的数据,边是「谁从谁算出来」。然后问三件事:

  1. 有几个真源? 购物车是一个对象,还是每个商品行一个独立开关?
  2. 谁会一起变? 一起变的,放一起订阅;从不一起变的,硬塞一个 store 会让无关组件重渲,或拆成原子却要手动同步。
  3. 寿命? 页面级、会话级、跨路由、要进 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. 取舍

ZustandJotai
图中心对象原子 DAG
订阅你写 selector依赖收集
心智Redux 简化Recoil / 电子表格
SSR单例易踩私有数据必须 Provider
工具Devtools 时间旅行熟图可视化看依赖
适合领域对象、编辑器文档大量离散状态、派生多

团队从 Redux 来、有清晰的聚合根 → Zustand。页面是格子、筛选器、每个格子独立 → Jotai。同一功能里不要两个真源。可以在应用级 Zustand 放会话,在某个复杂页局部用 Jotai——但要画清楚边界,禁止两边互相同步同一字段。

都不适合:表单的瞬时输入(useState)、服务器列表(Query)、能放 URL 的筛选(searchParams)。

10. 排障

  1. 无关组件跟着跳。 Zustand 没 selector,或 selector 返回新对象。Jotai 在组件里 useAtom 了过大的根原子。画订阅。
  2. 水合警告。 第一帧读了 localStorage。推迟 hydrate。
  3. 串用户。 Jotai 没 Provider;Zustand 模块单例塞了用户数据。
  4. 登出后还在。 store 在 layout,没重置。
  5. 和 Query 打架。 列表进了 store。删掉,只留 selectedId。
  6. 乐观对不上。 store 当了权威。权威回 props。
  7. Devtools 里 action 名叫 anonymous。 set 第三参给名字;或用 devtools 中间件。没法复盘的 store 等于没法排障。
  8. 测试里状态泄漏。 单例 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 变量):

  1. 树的上半部分读取了旧版本数据 v1;
  2. 此时外部状态被异步修改为 v2;
  3. 恢复渲染后,树的下半部分读取了新数据 v2;
  4. 最终呈现给用户的 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 垃圾回收器能够无障碍回收对应的原子实体与中间计算缓存,为超大型画布与复杂表单应用提供了极为稳固的内存底座。