Jotai v2: React状态管理的新篇章
v2 把 store 变成了公开 API。atom 里其实一个字节的状态都没有,值住在 store 的 WeakMap 里——理解了这件事,v2 改的那几处签名就都说得通了。
v2 最能说明问题的一段代码,一行 React 都没有:
import { atom, createStore } from 'jotai';
const countAtom = atom(0);const store = createStore();
store.set(countAtom, 42);console.log(store.get(countAtom)); // 42
const unsub = store.sub(countAtom, () => { console.log('变成了', store.get(countAtom));});store.set(countAtom, 43); // 打印「变成了 43」unsub();贴进 Node 里能直接跑。v1 做不到这件事——它导出的是 unstable_createStore,名字上就写着别用。v2 把 createStore 和 getDefaultStore 变成了正式导出,状态逻辑从此可以脱开组件树存在。
版本先交代清楚:v2.0.0 发布于 2023-01-31,下面引的源码按 v2.0.3(2023-03-02)这个 tag 读。v2 至今仍是当前大版本,最新是 2.20.2(2026-07-14),中间 store 的内部实现重组过(多了 vanilla/internals.ts),有些工具函数也是后来才加的,涉及到的地方我会标出来。
atom() 造出来的东西里没有状态
这是 Jotai 最容易被讲反的一点。atom(0) 返回的不是一个装着 0 的盒子,而是一份配置:
export function atom(read, write) { const key = `atom${++keyCount}`; const config = { toString: () => key, }; if (typeof read === 'function') { config.read = read; } else { config.init = read; config.read = (get) => get(config); config.write = (get, set, arg) => set(config, typeof arg === 'function' ? arg(get(config)) : arg); } if (write) { config.write = write; } return config;}整个函数就干了这么点事。config 上只有 toString、read、write、init 四个可能的 key,没有 value,没有订阅者列表,也没有任何通知机制。传进来的是函数就当 read 用(派生 atom),不是函数就存成 init(原始 atom),顺手配一对默认的 read/write。
那个 key 只用来 toString,方便调试时看。atom 的身份靠对象引用本身,不靠这个字符串——所以在组件里 atom(0) 会每次渲染造一个新 atom,这是新手最常踩的坑。
默认 write 里那句 typeof arg === 'function' ? arg(get(config)) : arg 也值得看一眼:setCount(c => c + 1) 这种函数式更新是原始 atom 自带的 write 实现的,不是 store 的能力。自己写 write 就没有它,除非自己也判一次。
值住在 store 里,一个 WeakMap
既然 atom 不存值,值在哪儿?在 store 里。createStore() 关起来的第一个变量就是它:
export const createStore = () => { const atomStateMap = new WeakMap<AnyAtom, AtomState>(); const mountedMap = new WeakMap<AnyAtom, Mounted>(); // 省略:pendingMap、依赖重算、批量 flush,六百行都在这里面
return { get: readAtom, set: writeAtom, sub: subscribeAtom, };};atomStateMap 以 atom 对象为键存值,mountedMap 存这个 atom 当前的订阅者。用 WeakMap 的好处是 atom 一旦没人引用,它在这个 store 里的那份状态自动就能被回收,不用手动注销。
公开出去的只有三个方法:get、set、sub。开发构建下还会多挂一串 dev_ 开头的东西给 devtools 用,注释里明写了随时会变,别碰。
这解释了 Provider 到底在干什么:它不注入 atom,它注入 store。
export const useStore = (options) => { const store = useContext(StoreContext); return options?.store || store || getDefaultStore();};三级兜底:优先用你在 options 里指定的 store,其次是 context 里的,最后落到全局默认 store。所以不写 <Provider> 也能用 Jotai——所有人共用 getDefaultStore() 那一个。写了 <Provider> 就是开一份独立的 store,同一个 atom 在两个 Provider 底下是两份互不相干的值。测试之间要隔离状态,办法就是每个用例给一个新 store。
到 2.20.2,createStore 和 getDefaultStore 这两个入口还在,store 的类型定义被挪进了 jotai/vanilla/internals。
useAtom 只是把 store.sub 接到一次重渲染上
知道了值在 store 里,React 这层就没什么悬念了:
useEffect(() => { const unsub = store.sub(atom, () => { if (typeof delay === 'number') { setTimeout(rerender, delay); return; } rerender(); }); rerender(); return unsub;}, [store, atom, delay]);
useDebugValue(value);return isPromise(value) ? use(value) : value;订阅、重渲染、卸载时退订。rerender 是一个 useReducer 派发出来的强制更新,reducer 里用 Object.is 比过新旧值,相等就返回原来那个元组,React 因此会跳过这次渲染。
细粒度更新的来源就在这儿,没有别的机关:订阅是按 atom 登记的,store.sub(atom, cb) 只在这一个 atom(以及它依赖的那些)变化时回调。这和 Context 的差别是结构性的——Context 变一次,整棵子树的消费者全部重渲染,而这里只有真正订阅了这个 atom 的组件会动。
最后一行也值得留意:atom 的值是 Promise 时,走的是 React 的 use()。use 还没在稳定版里的时候,文件顶上有一段手写的兜底,行为是把 promise 抛出去交给最近的 <Suspense>。所以异步 atom 天然配合 Suspense,不需要额外的 loading 状态——不想要这个行为的话,用 loadable() 把它包成一个不抛的对象。
v1 到 v2,签名改了这几处
| v1 | v2 | |
|---|---|---|
read |
(get) => Value |
(get, { signal, setSelf }) => Value |
write |
(get, set, update) => void | Promise<void> |
(get, set, ...args) => Result |
| store | 只有 unstable_createStore |
createStore / getDefaultStore 正式导出 |
Provider |
{ children, initialValues, scope, … } |
{ children, store } |
| 源码目录 | src/core/ |
src/vanilla/ + src/react/ |
write 那行是改动最大的:v1 只能收一个 update 参数、返回值被类型限死在 void | Promise<void>;v2 变成变长参数,而且可以返回任何东西。于是 set(someAtom, a, b) 这种多参数写法成立了,写函数也能把结果回传给调用方。
read 多出来的第二个参数里,signal 是一个 AbortSignal——派生 atom 重算时上一次的 signal 会被 abort,拿它去掐掉过期的 fetch 正合适。
顺便纠正一个常见的说法:v2 不是「去掉了必须包 Provider 的限制」,v1 就已经能不包了(它的 context 有默认值)。v2 真正改的是把那个默认 store 变成了公开 API,你能从外面拿到它、读它、写它。
源码目录那行不只是改名。v1 的 src/ 底下还堆着 query.ts、urql.ts、valtio.ts、xstate.ts、zustand.ts、immer.ts、optics.ts 一大排第三方集成,v2 把它们全部拆成了独立包,核心只剩 vanilla 和 react 两块。v2 里没有 core 这个模块,对着旧文章去 src/core/ 找是找不到的。
SSR 的初值靠 useHydrateAtoms 喂进去
这一节是纠错。网上抄来抄去的写法是这样的:
import { hydrateAtoms } from 'jotai/utils';
const hydratedAtoms = hydrateAtoms([[myAtom, initialValue]]);
<Provider initialValues={hydratedAtoms}>…</Provider>;hydrateAtoms 这个导出从来不存在,v1 和 v2 都没有过。Provider 的 initialValues 是 v1 的 prop,v2 删掉了,官方迁移说明给的理由是 store 更灵活。
真的东西叫 useHydrateAtoms,是个 hook,返回 void:
import { useHydrateAtoms } from 'jotai/utils';
function App({ initialCount }) { // 必须在组件里调,写在渲染阶段,不能放进 useEffect useHydrateAtoms([[countAtom, initialCount]]); return <Counter />;}实现只有十几行:拿到当前 store,遍历传进来的 [atom, value] 二元组,逐个 store.set。它用一个 WeakMap<Store, WeakSet<Atom>> 记住哪些 atom 在哪个 store 里已经喂过了,同一对只生效一次——第二次渲染再调不会把用户改过的值冲掉。
为什么必须显式喂:服务端和客户端各算一遍初值算不出同一个结果,凡是带随机数、时间戳、或者依赖请求上下文的初值都会对不上,React 注水时报 mismatch。这类问题的成因和排查思路,理解 Next.js 的 SSR , SSG 实现里讲得更细。
jotai/utils 里真正有什么
jotai/utils 只是个转发出口,export * from 'jotai/vanilla/utils' 加 export * from 'jotai/react/utils'。前者是纯函数,后者是 hook,分界线就是要不要 React。
| 导出 | 干什么 |
|---|---|
atomWithStorage(key, initial, storage?) |
值同步进 localStorage / sessionStorage,跨标签页也会同步 |
atomWithReset(initial) |
配 useResetAtom 用,能重置回初值 |
atomWithReducer(initial, reducer) |
reducer 风格的更新 |
atomWithDefault(get => …) |
初值由别的 atom 算出来,一旦写过就和来源脱钩 |
atomWithObservable(getObservable) |
接 RxJS 这类 observable |
atomFamily(initializeAtom, areEqual?) |
按参数生成一组 atom,列表场景用 |
selectAtom(anAtom, selector, equalityFn?) |
只订阅派生出来的一部分 |
splitAtom(arrayAtom) |
数组 atom 拆成一组可单独写的 item atom |
loadable(anAtom) |
异步 atom 包成 { state, data | error },不走 Suspense |
unwrap(anAtom, fallback?) |
异步 atom 取一个同步值 |
freezeAtom(anAtom) |
深冻结,开发期防止有人改了值不通知 |
useAtomCallback(cb) |
在回调里读写 atom 而不订阅它 |
useHydrateAtoms(values) |
SSR 初值 |
useResetAtom / useReducerAtom |
上面两个 atomWith* 的配套 hook |
有一条要单独说。流传的清单里常有一项「Lazy——延迟初始化」,配的例子是这个形状:
const lazyAtom = atom( () => expensiveComputation(), (get, set, newValue) => set(lazyAtom, newValue),);atom(read, write) 这个重载造出来的是可写的派生 atom,跟延迟初始化没有关系。它的值永远来自 read,写它不会改变它自己的值。而这段 write 又调 set(lazyAtom, …),等于调它自己——真跑起来是无限递归。
延迟初始化的正主叫 atomWithLazy(makeInitial),返回一个货真价实的原始 atom,init 是个 getter,第一次在 store 里被用到时才求值。注意它是后来才加的,2.0.3 里还没有。
什么时候不该用它
atom 定义在渲染函数里就废了。 每渲染一次造一个新对象,store 里就多一份状态,旧的还回收不掉——直到旧 atom 真的没人引用。要动态生成就用 atomFamily,它内部按参数缓存 atom 实例;但 atomFamily 默认永不清理,参数空间无界的时候得自己调 remove 或者设 setShouldRemove。
派生图深了不好查。 一个 atom 的值算错时,你要顺着 read 里的 get(xxxAtom) 一层层往上找。Redux 至少有一条线性的 action 日志可以回放,Jotai 没有对应的东西,devtools 能看的也有限。这是原子化换来的代价:更新粒度细了,因果链却散了。相比之下 Redux、MobX、Recoil、Zustand 、Jotai 对比里那几个集中式的库,调试路径反而短。
它不解决服务端状态。 请求缓存、失效重取、竞态取消这些事,atom(async get => fetch(...)) 只能做最表层的一点。真要管这些,该上 TanStack Query,让 Jotai 只存客户端自己的状态。
全局单例是默认行为。 不包 <Provider> 时所有人共用一个 store,测试之间会串状态。要么每个用例包一个新 Provider,要么显式建 store 传进去。