Jotai — 原子化 React 状态管理
已复核Jotai 把状态拆成一颗颗 atom(原子)。日常类比:zustand 是一本大家翻的账本;Jotai 是一堆卡片,组件只捏住自己那张。卡片的身份是 对象引用,不是名字字符串。
import { atom, useAtom } from 'jotai'
const countAtom = atom(0)
function Counter() { const [count, setCount] = useAtom(countAtom) return <button onClick={() => setCount((c) => c + 1)}>{count}</button>}atom(0) 在组件外创建一颗 primitive atom:内部带 init,默认 read 读自己,默认 write 接受值或 updater。useAtom 只是 useAtomValue + useSetAtom 的配对。
不读固定 2.20.3 源码,原子模型很容易被讲成「自动魔法」:
- 为什么派生 atom 不用手写依赖数组——
read里每次get(other)都会记入当前 atom 的依赖表 - 为什么把
atom()写进组件会「状态重置」——每次 render 都是新对象,store 用 WeakMap 按引用存值 - 为什么异步 atom 要包
<Suspense>——useAtomValue对 Promise 走React.use或抛 Promise 的 shim - 为什么没写
<Provider>也会共享状态——默认落到进程内单例getDefaultStore()
固定版本可以看成四层:
-
atom config:
atom()返回普通对象。有初始值就是 primitive;只传read是只读派生;再传write是可写派生。 -
store:
createStore()用 WeakMap 保存每颗 atom 的值、epoch、依赖和挂载信息。get/set/sub是对外入口。 -
依赖与失效:
read期间的get(dep)把dep -> epoch记进atomState.d。写入后沿 dependents 做 invalidate,再重算。 -
React 绑定:
useStore(options)优先级是options.store→ Context<Provider>→getDefaultStore()。异步值在 hook 层才变成 Suspense;vanillastore.get会直接拿到 Promise。
案例 1:派生 atom 自动记依赖
Section titled “案例 1:派生 atom 自动记依赖”import { atom } from 'jotai'
const countAtom = atom(0)const doubledAtom = atom((get) => get(countAtom) * 2)doubledAtom 没有 init。第一次 store.get(doubledAtom) 会跑 read;其中 get(countAtom) 把 countAtom 登记为依赖。之后只有 countAtom 的 epoch 变了,派生才会重算。这不是编译期分析,是运行时记录。
案例 2:异步 atom 与 Suspense
Section titled “案例 2:异步 atom 与 Suspense”const idAtom = atom(1)const userAtom = atom(async (get) => { const id = get(idAtom) const res = await fetch(`/api/users/${id}`) return res.json()})
function User() { const user = useAtomValue(userAtom) return <div>{user.name}</div>}read 的第二参数带 signal: AbortSignal。idAtom 先变时,上一轮未完成的 Promise 会被 abort,再接上新的 continuable promise。useAtomValue 看到 Promise 就交给 React.use(没有 React.use 时用会抛 Promise 的 shim)。没有外层 <Suspense> / Error Boundary,加载和失败都没有地方接。
案例 3:默认 store 与 Provider 隔离
Section titled “案例 3:默认 store 与 Provider 隔离”import { Provider, createStore, useAtom } from 'jotai'
function CountButton() { const [count, setCount] = useAtom(countAtom) return <button onClick={() => setCount((c) => c + 1)}>{count}</button>}
function DefaultStoreDemo() { return <CountButton /> // 无 Provider → getDefaultStore()}
const isolatedStore = createStore()
function IsolatedStoreDemo() { return ( <Provider store={isolatedStore}> <CountButton /> </Provider> )}两个组件可以并排渲染:DefaultStoreDemo 走默认单例,IsolatedStoreDemo 走自己的 store,点击互不影响。<Provider> 不传 store 时,会 useRef 建一份自己的 store,子树也不串值。多个 Jotai 副本同时调用 getDefaultStore() 时,开发模式会警告 default store 行为可能异常。
- atom 建在组件里:每次 render 新引用,WeakMap 看成新 atom,状态像被清空。按参数动态建 atom 时,固定 2.20.3 的
jotai/utilsatomFamily仍能用,但源码已标 deprecated,将在 v3 删除并迁到jotai-family。 - 把异步 atom 当普通 hook 数据:vanilla 层返回 Promise;React 层靠抛/use Promise。不要假设会得到
{ loading }对象,除非自己用loadable这类 utils。 - 误以为必须有 Provider:没有 Provider 时全体组件共享 default store。要隔离弹窗/测试,显式
createStore()+<Provider store>。 - 继续依赖
setSelf:readoptions 里的setSelf已标 deprecated,生产模式外会警告,计划在 v3 移除。
适用 vs 不适用场景
Section titled “适用 vs 不适用场景”适用:
- 状态天然拆成许多独立单元,派生多、共享边多
- 需要异步 atom 和 React Suspense 对齐的树
- 想在非 React 代码里直接
createStore().get/set
不适用:
- 只有少数全局 slice,团队已经习惯一个 store + selector → zustand
- 想直接
state.x++驱动渲染 → valtio - 需要把
atomFamily当成长期稳定 API → 固定 2.20.3 已宣布迁出核心 - 尚未在目标环境跑过的「比 Zustand 更细所以一定更快」
固定版本边界
Section titled “固定版本边界”- 本文绑定
pmndrs/jotai@3e0b9ffad54b2fbedf2165a82d06ae6bcf1ebd67,稳定 tag / npm latest 均为2.20.3。 - 同仓已有
v3.0.0-alpha.0/1,本页不讨论 alpha 合同。 - npm tarball 未提供
gitHead;升级前应重新核对 tag 与打包提交。 - 无生产
dependencies;React 17+ 是 React 入口的 optional peer。 - 本文未安装依赖、运行 vitest 或测量 bundle,状态保持
UNVERIFIED。
- atom 是引用 identity——名字只是给你看的;store 认的是对象本身。
- 依赖表是执行出来的——
get调用顺序决定图,不是装饰器或编译器。 - Suspense 是 React 适配,不是 store 语义——vanilla 只保存 Promise。
- 默认单例是隐式全局——Provider 是隔离工具,不是「使用 Jotai 的入场券」。
- 把
const countAtom = atom(0)写进函数组件。两次 render 之间,状态还会接上吗? userAtom的read返回 Promise。没有<Suspense>时,useAtomValue(userAtom)会得到普通对象吗?- 页面里没有任何
<Provider>。两个组件useAtom(countAtom),改其中一个会不会改到另一个?
检查点:
- 不会稳定接上。每次 render 都是新 atom 对象,WeakMap 存的是另一份状态。
- 不会。hook 会对 Promise 走
use/ 抛 Promise,没有边界时这是运行时失败,不是{ loading: true }。 - 会。双方都落到
getDefaultStore()单例。
- 官方文档:jotai.org
- 固定源码:pmndrs/jotai —— 本文绑定提交
3e0b9ffad54b2fbedf2165a82d06ae6bcf1ebd67 - zustand —— 集中 store + selector 对照
- valtio —— Proxy mutate 对照
- immer —— Immer — 用 Proxy 让你写”看起来可改”的代码却产出不可变状态
- nanostores —— nanostores — 不到 1 KB 的”框架无关”状态库
- react-hook-form —— react-hook-form — input 不进 React state 也能写表单
- valtio —— valtio — 让 state.x++ 直接驱动 React 重渲染的 Proxy 状态库
- xstate —— XState — 把状态画成图,让矛盾写不出来
- zustand —— Zustand — 极简 React 状态管理