Jotai v2: React状态管理的新篇章

v2 把 store 变成了公开 API。atom 里其实一个字节的状态都没有,值住在 store 的 WeakMap 里——理解了这件事,v2 改的那几处签名就都说得通了。

预计
9 分钟

v2 最能说明问题的一段代码,一行 React 都没有:

不需要 React,也不需要 Provider
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 把 createStoregetDefaultStore 变成了正式导出,状态逻辑从此可以脱开组件树存在。

版本先交代清楚: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 的盒子,而是一份配置:

jotai v2.0.3 · src/vanilla/atom.ts(节选)
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 上只有 toStringreadwriteinit 四个可能的 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() 关起来的第一个变量就是它:

jotai v2.0.3 · src/vanilla/store.ts(节选)
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 里的那份状态自动就能被回收,不用手动注销。

公开出去的只有三个方法:getsetsub。开发构建下还会多挂一串 dev_ 开头的东西给 devtools 用,注释里明写了随时会变,别碰。

这解释了 Provider 到底在干什么:它不注入 atom,它注入 store。

jotai v2.0.3 · src/react/Provider.ts(节选)
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,createStoregetDefaultStore 这两个入口还在,store 的类型定义被挪进了 jotai/vanilla/internals

useAtom 只是把 store.sub 接到一次重渲染上

知道了值在 store 里,React 这层就没什么悬念了:

jotai v2.0.3 · src/react/useAtomValue.ts(节选)
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.tsurql.tsvaltio.tsxstate.tszustand.tsimmer.tsoptics.ts 一大排第三方集成,v2 把它们全部拆成了独立包,核心只剩 vanillareact 两块。v2 里没有 core 这个模块,对着旧文章去 src/core/ 找是找不到的。

SSR 的初值靠 useHydrateAtoms 喂进去

这一节是纠错。网上抄来抄去的写法是这样的:

这段是错的,两处都错
import { hydrateAtoms } from 'jotai/utils';
const hydratedAtoms = hydrateAtoms([[myAtom, initialValue]]);
<Provider initialValues={hydratedAtoms}>…</Provider>;

hydrateAtoms 这个导出从来不存在,v1 和 v2 都没有过。ProviderinitialValues 是 v1 的 prop,v2 删掉了,官方迁移说明给的理由是 store 更灵活。

真的东西叫 useHydrateAtoms,是个 hook,返回 void

jotai/utils · useHydrateAtoms 的正确用法
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——延迟初始化」,配的例子是这个形状:

这不是 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 传进去。