
一句话:Jotai 的模型是「自下而上的原子化状态」——atom 是最小状态单元,派生 atom 用「读取时自动追踪依赖」的方式自动建立依赖图,所以它不需要选择器、不需要 reducer、也不需要「把状态提到顶层」;理解它的关键是「
useAtomValue(读)/useSetAtom(写)的分离」与「原子粒度决定性能」。
一、与「单一 store」的范式差异
⭐⭐⭐ 两种范式(决定了它们适合的场景):
【自上而下(Redux / Zustand)】
const useStore = create((set) => ({ count: 0, step: 1, ... }));
组件: const count = useStore((s) => s.count);
· ⭐ 状态「集中在一处」,组件用「选择器」取自己需要的部分
· 优点:单一真相源、容易做「整体持久化/时间旅行/中间件」
· 代价:新增状态就「改 store 定义」;派生值要「手写选择器 + 记忆化」
【自下而上(Jotai / Recoil)】
const countAtom = atom(0);
const doubledAtom = atom((get) => get(countAtom) * 2); // ⭐ 派生自动追踪
组件: const [count, setCount] = useAtom(countAtom);
· ⭐ 状态「按需分裂成原子」,派生值用「普通函数」表达(依赖自动追踪)
· 优点:不需要选择器、不需要「提升状态」、天然细粒度、组合能力强
· 代价:原子散落各处(需要约定放哪里);调试时「状态不在一个地方」
⭐⭐ 一个具体例子(感受「派生」的差别):
【Zustand 的写法】
const useStore = create((set) => ({
items: [],
coupon: null,
setItems: (items) => set({ items }),
setCoupon: (coupon) => set({ coupon }),
}));
function Total() {
// ⭐ 派生值要「写在选择器里」,且要注意「返回新对象」的问题(见 11.13)
const total = useStore(useShallow((s) => {
const sum = s.items.reduce((a, i) => a + i.price * i.qty, 0);
return s.coupon === 'HALF' ? sum / 2 : sum;
}));
return <span>{total}</span>;
}
【Jotai 的写法】
const itemsAtom = atom<Item[]>([]);
const couponAtom = atom<string | null>(null);
// ⭐ 派生 atom:就是一个「读取其它 atom 的函数」
const totalAtom = atom((get) => {
const sum = get(itemsAtom).reduce((a, i) => a + i.price * i.qty, 0);
return get(couponAtom) === 'HALF' ? sum / 2 : sum;
});
function Total() {
const total = useAtomValue(totalAtom); // ⭐ 不需要选择器、不需要 useShallow
return <span>{total}</span>;
}
⭐ 关键差别:
· Zustand 里「派生」是「在选择器里算」(每次订阅都要重算 + 比较)
· Jotai 里「派生」是「一个独立的、有缓存的计算单元」
(依赖不变就不重算;依赖变了才重算并通知订阅者)
二、atom 的四种形态
// ============ ① 原始 atom(可读写) ============
const countAtom = atom(0);
const userAtom = atom<User | null>(null);
// ============ ② 只读派生 atom ============
const doubledAtom = atom((get) => get(countAtom) * 2);
// ⭐ 派生可以组合(图会很大,但每个节点都是懒计算 + 缓存)
const cartAtom = atom<CartItem[]>([]);
const couponAtom = atom<string | null>(null);
const subtotalAtom = atom((get) => get(cartAtom).reduce((a, i) => a + i.price * i.qty, 0));
const discountAtom = atom((get) => (get(couponAtom) === 'HALF' ? get(subtotalAtom) / 2 : 0));
const totalAtom = atom((get) => get(subtotalAtom) - get(discountAtom));
// ============ ③ 可写派生 atom(读 + 写都是函数) ============
/** ⭐ 这是 Jotai 最有威力的能力:把「一个逻辑操作」封装成一个 atom */
const addItemAtom = atom(
null, // ⭐ 第一个参数为 null:表示「不直接暴露值」
(get, set, item: Omit<CartItem, 'qty'>) => {
const items = get(cartAtom);
const existing = items.find((i) => i.id === item.id);
if (existing) {
set(cartAtom, items.map((i) => (i.id === item.id ? { ...i, qty: i.qty + 1 } : i)));
} else {
set(cartAtom, [...items, { ...item, qty: 1 }]);
}
}
);
// 使用:const addItem = useSetAtom(addItemAtom); addItem({ id: '1', name: 'x', price: 9 });
// ============ ④ 异步 atom ============
const userAtomAsync = atom(async (get) => {
const id = get(userIdAtom);
// ⚠️ 没有取消机制 → 依赖变化时「旧请求仍会完成」(Jotai 只会用最新的那个结果)
const res = await fetch(`/api/users/${id}`);
return res.json();
});
// 使用:配合 Suspense(见 4.16)
function Profile() {
const user = useAtomValue(userAtomAsync); // ⭐ 未就绪时会「挂起」
return <div>{user.name}</div>;
}
function App() {
return (
<Suspense fallback={<Skeleton />}>
<Profile />
</Suspense>
);
}
⭐⭐⭐ 「可写派生 atom」为什么重要(这是它超越「选择器」的核心):
【问题】在 Redux/Zustand 里,「一次业务操作」要么是:
· action + reducer(Redux)
· store 里的一个方法(Zustand)
→ ⭐ 都要求「把逻辑放进 store 定义文件」
【Jotai 的做法】
把「操作」也做成 atom:
const addItemAtom = atom(null, (get, set, item) => { ... });
⭐ 于是「逻辑可以按业务就近定义」(甚至和 UI 组件放在一起),
而且「它天然拿到最新的 state」(不用 get()/不用闭包技巧)
⭐ 配合 `useSetAtom` 的收益(性能关键):
const addItem = useSetAtom(addItemAtom);
→ ⭐ 这个组件**永远不会因为「值变化」而重渲染**(它只写,不读)
(对比:`const [{ items }, setItems] = useState()` 时「items 一变就重渲染」)
三、读 / 写的分离:性能的关键
// ⭐⭐⭐ 三个 Hook 的区别(用错就会「无谓重渲染」)
// ① useAtomValue:只读
const count = useAtomValue(countAtom);
// ⭐ 不返回 setter → 「不会因为 setter 引用变化而重渲染」
// ✅ 适合:「只显示」的组件
// ② useSetAtom:只写
const increment = useSetAtom(countAtom);
// ⭐⭐ 关键:它「不订阅值」→ **值变化时这个组件不重渲染**!
// ✅ 适合:「按钮/事件处理器」组件
// ③ useAtom:读 + 写
const [count, setCount] = useAtom(countAtom);
// ⚠️ 订阅了值 → 值变化时重渲染
// ✅ 只在「既显示又要改」时才用
// ============ 一个具体的对比 ============
// ❌ 常见写法:按钮组件订阅了值(无谓重渲染)
function IncrementButtonBad() {
const [count, setCount] = useAtom(countAtom); // ⚠️ count 一变就重渲染
return <button onClick={() => setCount((c) => c + 1)}>+ ({count})</button>;
}
// ✅ 分离:显示用 useAtomValue,操作用 useSetAtom
function CountDisplay() {
const count = useAtomValue(countAtom); // 只订阅值
return <span>{count}</span>;
}
function IncrementButton() {
const setCount = useSetAtom(countAtom); // ⭐ 完全不重渲染(除非卸载)
return <button onClick={() => setCount((c) => c + 1)}>+</button>;
}
// ⭐ 效果:点按钮时,「只有 CountDisplay 重渲染」;IncrementButton 一次都不渲染
⭐⭐⭐ 「原子粒度」该怎么定(这是用 Jotai 最重要的设计决策):
【原则】⭐ 「**按『变化频率』和『消费范围』分裂**」——
一个 atom 应该「一起变的东西放在一起,不一起变的分开放」。
【例子:一个表单】
❌ 一个 atom 装整个表单对象:
const formAtom = atom({ email: '', password: '', age: 18 });
→ 改 email 会让「所有订阅 formAtom 的组件」重渲染
✅ 拆成「每个字段一个 atom」(或用一个 atom 装对象 + 「字段级派生 atom」):
const emailAtom = atom('');
const passwordAtom = atom('');
const ageAtom = atom(18);
→ 改 email 只影响「订阅 email 的那个输入框 + 依赖它的派生值」
【但不要拆过头】
⚠️ 「100 个 atom」会让「代码难读、调试困难」
⭐ 经验:「**原子对应『独立变化的数据源』**」(字段、选中项、筛选条件),
而不是「对象的每个属性都拆一个」。
【一个实用的中间方案(Jotai 的 atomFamily / focusAtom 模式)】
const fieldAtom = atomFamily((name: string) => atom('')); // 按名字生成 atom
// 或用一个「对象 atom + 选择器派生」:
const formAtom = atom({ email: '', password: '' });
const emailAtom = atom(
(get) => get(formAtom).email,
(get, set, value: string) => set(formAtom, { ...get(formAtom), email: value })
);
⭐ 好处:「存储是一份(好持久化),但订阅是字段级(好性能)」
四、Provider 与「多实例状态」
// ⭐ 默认情况下,atom 的值是「全局单例」(存在默认 store 里)
// 需要「多实例」(如「同一份组件在两处独立使用」)时,用 Provider
import { Provider, createStore } from 'jotai';
// ✅ 用法 1:在应用根部包一层(大多数项目不需要,但有助于测试与 SSR)
function App() {
return (
<Provider>
<Routes />
</Provider>
);
}
// ✅ 用法 2:为「每个实例」创建独立的 store(⭐ 解决「全局单例」的限制)
function CommentBox({ postId }: { postId: string }) {
// ⭐ 每个 CommentBox 有自己的 store → 各自的 atom 值互不干扰
const store = useMemo(() => createStore(), []);
return (
<Provider store={store}>
<CommentEditor />
<CommentList />
</Provider>
);
}
// ⚠️ 注意:store 必须在「组件之间共享」——
// 如果 Provider 的 store 每次渲染都新建,那「状态每次渲染都重置」
// ✅ 用法 3:SSR(每个请求一个 store,避免「请求间串数据」)
// server.ts
// const store = createStore();
// // 渲染时用这个 store,渲染完丢弃
// ⭐ 这是「为什么 SSR 下要用 Provider」的核心原因(见 9.5)
五、完整实现:一个 mini-jotai(260 行)
// mini-jotai.ts —— 复现 Jotai 的核心(依赖自动追踪 + 派生缓存 + 失效传播)
// ⚠️ 教学实现:省略了「异步 atom 的调度」「atomFamily」「Provider 的完整语义」等
import { useMemo, useSyncExternalStore, useRef, useCallback } from 'react';
/** ============ 1. atom 的形态 ============ */
type Getter = <V>(a: Atom<V>) => V;
type Setter = <V>(a: WritableAtom<V, unknown>, arg: unknown) => void;
interface Atom<V> {
/** 原始值(没有 read 时用它) */
init?: V;
/** 读函数(有它就是「派生 atom」) */
read?: (get: Getter) => V;
/** 写函数(有它就是「可写 atom」) */
write?: (get: Getter, set: Setter, arg: unknown) => void;
toString: () => string;
__id: string;
}
type WritableAtom<V, Arg> = Atom<V> & { write?: (get: Getter, set: Setter, arg: Arg) => void };
let atomIdCounter = 0;
/** ⭐ 重载:atom(value) | atom(readFn) | atom(readFn, writeFn) */
export function atom<V>(init: V): WritableAtom<V, V>;
export function atom<V>(read: (get: Getter) => V): Atom<V>;
export function atom<V, Arg>(
read: (get: Getter) => V,
write: (get: Getter, set: Setter, arg: Arg) => void
): WritableAtom<V, Arg>;
export function atom(configOrInit: unknown, write?: unknown): Atom<unknown> {
const a: Atom<unknown> = { toString: () => 'atom', __id: `atom-${++atomIdCounter}` };
if (typeof configOrInit === 'function' && write === undefined) {
a.read = configOrInit as (get: Getter) => unknown;
} else if (typeof configOrInit === 'function') {
a.read = configOrInit as (get: Getter) => unknown;
a.write = write as Atom<unknown>['write'];
} else if (write !== undefined) {
// ⭐ atom(null, writeFn):只写的 atom(本实现在 read 时返回 null)
a.init = configOrInit;
a.read = () => configOrInit;
a.write = write as Atom<unknown>['write'];
} else {
a.init = configOrInit;
}
return a;
}
/** ============ 2. store 内核:依赖图 + 缓存 + 失效传播 ============ */
interface AtomState {
/** 当前值 */
value: unknown;
/** 是否已有值(区分「值为 undefined」与「还没算过」) */
hasValue: boolean;
/** 是否「脏」(需要重算) */
dirty: boolean;
/** ⭐ 我依赖谁 */
deps: Set<Atom<unknown>>;
/** ⭐ 谁依赖我(反向边,用于失效传播) */
dependents: Set<Atom<unknown>>;
/** 直接订阅这个 atom 的监听器(来自 React) */
listeners: Set<() => void>;
}
export function createStore() {
const states = new WeakMap<Atom<unknown>, AtomState>();
const getState = (a: Atom<unknown>): AtomState => {
let s = states.get(a);
if (!s) {
s = { value: undefined, hasValue: false, dirty: true, deps: new Set(), dependents: new Set(), listeners: new Set() };
states.set(a, s);
}
return s;
};
/** ⭐⭐ 当前「正在计算」的 atom(依赖追踪的锚点) */
let currentlyComputing: Atom<unknown> | null = null;
/** ============ 读:计算 + 追踪依赖 ============ */
function readAtom<V>(a: Atom<V>): V {
const state = getState(a);
// ⭐ ① 依赖追踪:把「我」登记为「读取者」
if (currentlyComputing && currentlyComputing !== a) {
state.dependents.add(currentlyComputing);
getState(currentlyComputing).deps.add(a);
}
// ② 派生 atom:需要时重算
if (a.read) {
if (state.dirty || !state.hasValue) {
// ⭐⭐ 关键:重算前「清掉旧依赖」(否则依赖会泄漏,导致无谓重算)
for (const dep of state.deps) {
getState(dep).dependents.delete(a);
}
state.deps.clear();
const prevComputing = currentlyComputing;
currentlyComputing = a;
try {
state.value = a.read(readAtom as Getter);
state.hasValue = true;
state.dirty = false;
} finally {
currentlyComputing = prevComputing;
}
}
return state.value as V;
}
// ③ 原始 atom:首次读时用 init
if (!state.hasValue) {
state.value = a.init;
state.hasValue = true;
state.dirty = false;
}
return state.value as V;
}
/** ============ 写:更新 + 失效传播 + 通知 ============ */
function writeAtom<V>(a: Atom<V>, arg: unknown): void {
const state = getState(a);
// ⭐ ① 可写派生 atom:调用 write,由它决定「改哪个底层 atom」
if (a.write) {
const prevComputing = currentlyComputing;
currentlyComputing = null; // ⭐ 写操作里不追踪依赖
try {
a.write(readAtom as Getter, writeAtom as Setter, arg);
} finally {
currentlyComputing = prevComputing;
}
return;
}
// ⭐ ② 原始 atom:直接写值(支持「函数式更新」)
const prev = state.hasValue ? state.value : a.init;
const next = typeof arg === 'function' ? (arg as (p: unknown) => unknown)(prev) : arg;
if (Object.is(prev, next)) return; // ⭐ 值没变 → 什么都不做
state.value = next;
state.hasValue = true;
state.dirty = false;
// ⭐⭐ ③ 失效传播:把所有「传递依赖我的」派生 atom 标脏 + 通知监听器
propagate(a, new Set());
}
function propagate(a: Atom<unknown>, visited: Set<Atom<unknown>>) {
if (visited.has(a)) return; // ⭐ 防环
visited.add(a);
const state = getState(a);
// 先通知「直接订阅这个 atom 的」
for (const listener of state.listeners) listener();
// 再向上传播给「依赖我的派生 atom」
for (const dependent of state.dependents) {
const depState = getState(dependent);
depState.dirty = true; // ⭐ 标记脏(下次读时才重算 = 懒计算)
propagate(dependent, visited);
}
}
/** ============ 订阅(给 useSyncExternalStore 用) ============ */
function subscribe(a: Atom<unknown>, listener: () => void): () => void {
const state = getState(a);
state.listeners.add(listener);
// ⭐ 订阅时「先建立依赖关系」(通过读一次)
readAtom(a);
return () => {
state.listeners.delete(listener);
};
}
return { readAtom, writeAtom, subscribe, getState };
}
type Store = ReturnType<typeof createStore>;
/** ============ 3. React 集成 ============ */
import { createContext, useContext } from 'react';
const StoreContext = createContext<Store | null>(null);
export function Provider({ store: storeProp, children }: { store?: Store; children: React.ReactNode }) {
// ⭐ 不传 store 时创建「这个 Provider 专属的」store
const store = useMemo(() => storeProp ?? createStore(), [storeProp]);
return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>;
}
/** ⭐ 默认 store(不包 Provider 时用它,实现「全局单例」) */
let defaultStore: Store | null = null;
function getDefaultStore(): Store {
if (!defaultStore) defaultStore = createStore();
return defaultStore;
}
function useStore(): Store {
return useContext(StoreContext) ?? getDefaultStore();
}
/**
* ⭐⭐ useAtomValue:只读订阅。
* 关键点:getSnapshot 必须返回「稳定的引用」,否则 useSyncExternalStore 会无限循环。
* 这里因为「atom 的值本身就是引用稳定的」(派生 atom 有缓存),所以可以直接返回。
* ⚠️ 但如果派生 atom 「每次都返回新对象」(如 `atom(get => ({ a: get(aAtom) }))`),
* 就会「每次读都新引用」→ ⭐ **这是 Jotai 最需要避免的写法**
*/
export function useAtomValue<V>(a: Atom<V>): V {
const store = useStore();
const getSnapshot = useCallback(() => store.readAtom(a), [store, a]);
return useSyncExternalStore(
(listener) => store.subscribe(a, listener),
getSnapshot,
getSnapshot // SSR 快照(这里简化)
);
}
/** ⭐⭐ useSetAtom:只写,**不订阅值** → 值变化时这个组件不重渲染 */
export function useSetAtom<V, Arg>(a: WritableAtom<V, Arg>) {
const store = useStore();
return useCallback((arg: Arg) => { store.writeAtom(a, arg); }, [store, a]);
}
/** useAtom:读 + 写 */
export function useAtom<V, Arg>(a: WritableAtom<V, Arg>): [V, (arg: Arg) => void] {
const value = useAtomValue(a as unknown as Atom<V>);
const setValue = useSetAtom(a);
return [value, setValue];
}
/** ⭐ useAtomCallback(读取最新值但不订阅)—— 用于「事件里读状态」 */
export function useAtomCallback<V>(a: Atom<V>) {
const store = useStore();
return useCallback(() => store.readAtom(a), [store, a]);
}
/** ============ 4. 工具:atomFamily(按参数生成 atom) ============ */
export function atomFamily<P, V>(create: (param: P) => Atom<V>) {
const cache = new Map<string, Atom<V>>();
const family = (param: P): Atom<V> => {
const key = JSON.stringify(param);
let a = cache.get(key);
if (!a) {
a = create(param);
cache.set(key, a);
}
return a;
};
/** ⭐ 手动清理(避免「无限增长」—— 参数是「用户 id」这类会膨胀的值时要注意) */
family.remove = (param: P) => { cache.delete(JSON.stringify(param)); };
family.clear = () => { cache.clear(); };
return family;
}
/** ============ 5. 使用示例 ============ */
import { atom, useAtomValue, useSetAtom, Provider } from './mini-jotai';
const countAtom = atom(0);
const stepAtom = atom(1);
const doubledAtom = atom((get) => get(countAtom) * 2);
const labelAtom = atom((get) => `当前 ${get(countAtom)},步长 ${get(stepAtom)},两倍 ${get(doubledAtom)}`);
// ⭐ 可写派生 atom:把「业务操作」封装成 atom
const bumpAtom = atom(null, (get, set) => {
set(countAtom, get(countAtom) + get(stepAtom));
});
function Display() {
const label = useAtomValue(labelAtom); // ⭐ 依赖 count/step/doubled,自动追踪
const doubled = useAtomValue(doubledAtom);
return (
<div>
{label}
两倍:{doubled}
</div>
);
}
function Controls() {
const bump = useSetAtom(bumpAtom); // ⭐ 只写 → 值变化时【不重渲染】
const setStep = useSetAtom(stepAtom);
return (
<div>
<button onClick={() => bump(undefined as never)}>+step</button>
<button onClick={() => setStep((s: number) => s + 1)}>步长 +1</button>
</div>
);
}
export function App() {
return (
<Provider>
<Display />
<Controls />
</Provider>
);
}
⭐⭐⭐ 这个实现复现了 Jotai 的四个核心机制:
① 【依赖自动追踪(读取时记录)】
用一个「当前正在计算的 atom」(`currentlyComputing`)作为锚点:
派生 atom 计算时调用 `get(otherAtom)` → 建立「双向边」
(`deps` 与 `dependents`)。
⭐ 这就是「不需要手写依赖数组」的实现原理。
② 【重算前清空旧依赖】⭐ 最容易漏的一步
如果不清,会出现「依赖只增不减」——
⭐ 症状:改了「一个已经不相关的 atom」也会触发重算(因为旧依赖边还在)。
③ 【懒计算 + 缓存】
写入时**不立刻重算**派生 atom,只「标脏」(★ `dirty = true`),
⭐ 等「有人读的时候」才算(lazy)→ 所以「没人看的派生值不花计算」。
④ 【失效传播(propagate)】
写入时沿 `dependents` 逐层向上传播(标记脏 + 通知直接订阅者)。
⭐ 用 `visited` 防环(虽然正常的 atom 图是有向无环的,
但推导链上可能出现「菱形依赖」,需要去重)。
六、本篇特有的坑
// ① 派生 atom 每次都返回「新对象」(导致无谓重渲染 + 可能的无限循环)
const derivedAtom = atom((get) => ({ count: get(countAtom) })); // ❌ 每次读都是新对象
// ✅ 方案 A:直接返回基本类型
const derivedAtom = atom((get) => get(countAtom));
// ✅ 方案 B:拆成多个原子
const countAtom = ...; const nameAtom = ...;
// ✅ 方案 C:用「带比较」的订阅(Jotai 的 `useAtom` 不支持自定义比较,
// 但可以用 `selectAtom` 或「把计算放进组件的 useMemo」)
// ② 用 useAtom 而不是 useSetAtom(按钮组件无谓重渲染)
const [count, setCount] = useAtom(countAtom); // ⚠️ 订阅了值
// ✅ const setCount = useSetAtom(countAtom);(只写 → 不重渲染)
// ③ 「原子粒度」太粗(一个 atom 装整个表单 → 改一个字段全表重渲染)
const formAtom = atom({ email: '', password: '' }); // ⚠️
// ✅ 拆成字段级 atom,或用「对象 atom + 字段级派生 atom」
// ④ 「原子粒度」太细(上百个 atom,代码难读也没收益)
// ✅ 按「独立变化的数据源」切分,不要「每个属性一个 atom」
// ⑤ 在「渲染期间」写 atom(触发其它组件更新 → 可能死循环)
function Bad() {
const setX = useSetAtom(xAtom);
setX(1); // ❌ 渲染里写
return null;
}
// ✅ 只允许在「事件处理/Effect/异步回调」里写
// ⑥ Provider 的 store「每次渲染都新建」(状态每次重置)
<Provider store={createStore()}> // ❌
// ✅ const store = useMemo(() => createStore(), []); 或直接 <Provider>
// ⑦ SSR 时不隔离 store(请求间串数据)
// ⭐ 症状:A 用户看到 B 用户的数据(严重)
// ✅ 每个请求 createStore() + Provider
// ⑧ 异步 atom 依赖变化但「旧请求仍在跑」(结果被丢弃但浪费了请求)
const a = atom(async (get) => { const id = get(idAtom); return fetch(`/x/${id}`); }); // ⚠️
// ⭐ Jotai 只会采用「最后一次计算」的结果(旧的会被忽略),但请求没被取消
// ✅ 用 AbortController:把 signal 也做成逻辑的一部分(或在 Effect 里做请求)
// ⑨ atomFamily 的「内存不释放」(参数是 userId 之类会无限增长)
const userAtom = atomFamily((id: string) => atom(null)); // ⚠️ 从不清理
// ✅ 组件卸载时 `userAtom.remove(id)`;或用 LRU
// ⑩ 「原子定义在组件内部」(每次渲染都新建 atom → 状态丢失)
function Bad() {
const localAtom = atom(0); // ❌ 每次渲染新 atom
return <Child atom={localAtom} />;
}
// ✅ atom 定义在「模块级」(或配合 useMemo/atomFamily)
// ⑪ 在「共享 atom」和「组件 props」之间摇摆(不确定状态该放哪)
// ⭐ 判据:「这个状态需要跨组件共享吗」——
// 不需要 → 用 useState(最简单);
// 需要但不全局 → 用「局部 Provider + atom」;
// 全局 → 模块级 atom
// ⑫ 用「多个 atom」表达「必须一起变」的数据(中间态可见)
const isSubmittingAtom = atom(false);
const errorAtom = atom<string | null>(null);
// ⚠️ 两个 setAtom 之间会有「短暂的不一致状态」
// ✅ 「必须原子性变化」的东西用一个 atom 装(或用可写派生 atom 一次性改)
// ⑬ 以为「atom 的值变化一定会触发所有依赖方重渲染」
// ⭐ 实际是「读它的组件才重渲染」——
// 而「只依赖它但不订阅它的派生 atom」不重渲染(只在被读时重算)
// ⑭ 「写派生 atom 时读同一个 atom」(自我依赖)
const a = atom(null, (get, set) => { set(a, get(a) + 1); }); // ⚠️ 逻辑混乱
// ✅ 写「底层 atom」,而不是写「自己」
// ⑮ 用 Jotai 做「全局服务/副作用」(如「监听路由变化」)
// ⚠️ atom 是「状态容器」,不是「副作用容器」
// ✅ 副作用放 Effect(jotai 生态里的 `atomWithSubscription` 之类也是包装 Effect)
// ⑯ 忘记「持久化时只存『原始 atom』」
// ⭐ 派生 atom 不需要存(它会被算出来)
// ✅ 只持久化「底层 atom」(或按 key 白名单)
// ⑰ 「调试困难」(状态散落各处)
// ✅ 用 Jotai DevTools(`jotai-devtools`)+ 「给 atom 起名」(`atom(0)` 加 `debugLabel`)
// ⭐ 这也是「自下而上」范式的固有代价
// ⑯ 的完整处理(只持久化「底层 atom」)
import { atomWithStorage } from 'jotai/utils';
// ✅ 底层 atom 用 atomWithStorage(会自动同步 localStorage)
const themeAtom = atomWithStorage('theme', 'light');
const cartAtom = atomWithStorage<CartItem[]>('cart', []);
// ✅ 派生 atom 用普通 atom(不持久化,会被算出来)
const cartCountAtom = atom((get) => get(cartAtom).reduce((a, i) => a + i.qty, 0));
// ⚠️ 注意「版本迁移」问题(与 11.13 的 persist 中间件同源):
// 数据结构变化时要「版本号 + migrate」,否则会出现「state 缺字段」的诡异 bug
// ⑨ 的完整处理(atomFamily 的内存管理)
// 组件里:
function UserCard({ id }: { id: string }) {
const userAtom = userAtomFamily(id);
const user = useAtomValue(userAtom);
// ⭐ 卸载时移除(避免「浏览过 1000 个用户 → 1000 个 atom 常驻」)
useEffect(() => () => { userAtomFamily.remove(id); }, [id]);
return <div>{user?.name}</div>;
}
七、面试延伸
- 「Jotai 和 Zustand/Redux 的模型有什么不同?」
范式不同:Redux/Zustand 是「自上而下」——「单一 store + 选择器」,状态集中在一处,组件用选择器取自己需要的部分(优点是单一真相源、容易做整体持久化/时间旅行;代价是新增状态要改 store 定义、派生值要手写选择器并注意记忆化);Jotai 是「自下而上」——⭐「原子化状态」,atom是最小单元,派生 atom 用atom((get) => ...)这种「普通函数」表达,依赖被自动追踪(不需要选择器、不需要提升状态、天然细粒度)。⭐ 一个关键差别:Zustand 里「派生」是「在选择器里算」(每次订阅都要重算 + 比较),而 Jotai 里「派生」是「一个独立的、有缓存的计算单元」(依赖不变不重算)。 -
「Jotai 的『依赖自动追踪』是怎么实现的?」
核心是「读取时记录」:① 用一个「当前正在计算的 atom」(如currentlyComputing)作为锚点;② 派生 atom 计算时,每调用一次get(otherAtom),就建立双向边(在「我」的deps里加它,在「它」的dependents里加「我」);③ 写入时沿dependents逐层向上传播(标记dirty+ 通知直接订阅者)。⭐ 两个关键细节:重算前必须清空旧依赖(否则「依赖只增不减」,改一个已经不相关的 atom 也会触发重算);写入时不立刻重算,只标脏(⭐ 懒计算——等「有人读」时才算,所以「没人看的派生值不花计算」)。 -
「
useAtomValue/useSetAtom/useAtom有什么区别?」
①useAtomValue——只读,不返回 setter,⭐ 所以「不会因为 setter 引用变化而重渲染」;② ⭐⭐useSetAtom——只写,不订阅值 → ⭐ 值变化时这个组件完全不重渲染(这是性能关键);③useAtom——读 + 写,订阅了值,值变化时重渲染。实践纪律:「显示用useAtomValue,操作用useSetAtom」——一个「只负责触发操作」的按钮组件用useSetAtom后,点多少次都不会让它自己重渲染(只有真正显示值的组件重渲染)。 -
「原子粒度怎么定?」
原则是「按『变化频率』和『消费范围』分裂」——⭐ 「一起变的东西放在一起,不一起变的分开放」。太粗(❌ 一个 atom 装整个表单对象)→ 改一个字段会让所有订阅者重渲染;太细(❌ 上百个 atom)→ 代码难读、调试困难、也没额外收益。实用建议:原子对应「独立变化的数据源」(字段、选中项、筛选条件),而不是「对象的每个属性」。中间方案:⭐ 「对象 atom + 字段级派生 atom」(存储是一份,好持久化;订阅是字段级,好性能),或用atomFamily按参数生成。 -
「为什么说『派生 atom 返回新对象』是个坑?」
因为useSyncExternalStore的判断逻辑是「比较前后两次getSnapshot()的返回值」(见 11.13)——而atom((get) => ({ count: get(countAtom) }))每次读都返回新对象 → ⭐ React 会认为「快照一直在变」→ 无谓重渲染,严重时无限重渲染(开发期会报 “The result of getSnapshot should be cached”)。解法:① 直接返回基本类型(atom((get) => get(countAtom)));② 拆成多个原子;③ 用「带比较的订阅」(selectAtom或「把计算放进组件的useMemo」)。⭐ 注意:派生 atom 本身是缓存的——如果它依赖的原子没变,它返回的是同一个引用;问题出在「计算结果本身就是新对象」时。 -
「Jotai 的 Provider 有什么用?」
默认情况下 atom 的值存在「默认 store(全局单例)」里;Provider 提供「作用域化的 store」,三个用途:① ⭐ 多实例——「同一个组件在两处独立使用,各自一份状态」(如「页面上有两个独立的评论框」),做法是「每个实例useMemo(() => createStore(), [])+<Provider store={store}>」;② ⭐ SSR 隔离——每个请求一个 store,渲染完丢弃,否则「请求之间会串数据」(⭐ 这是「为什么 SSR 下必须用 Provider」的核心原因,见 9.5);③ 测试隔离——每个测试用例一个 store。⚠️ 两个坑:<Provider store={createStore()}>(每次渲染新建 → 状态每次重置);「Provider 外的 atom 仍然用默认 store」。 -
「Jotai 适合什么样的项目?」
适合:① ⭐ 派生值多、依赖关系复杂的场景(表格的行列合计、多条件筛选联动、看板的分组统计)——因为「派生 atom 会自动追踪依赖并缓存」;② ⭐ 需要「按需细粒度订阅」的高频更新场景(表单、拖拽、实时数据);③ 需要『可组合的逻辑单元』(把「一次业务操作」封装成可写派生 atom,让逻辑靠近使用处);④ 想避免『把状态一路提到顶层』的中大型应用。不太适合:① 需要「时间旅行/完整事件溯源」(Redux 更合适);② 需要「单一 store + 中间件生态」(Zustand 更直接);③ ⭐ 很小的项目(useState就够了,别为了「架构漂亮」引入状态库)。 -
「Jotai 的『可写派生 atom』有什么用?」
它把「一次业务操作」封装成一个 atom:atom(readFn | null, (get, set, arg) => { ... })。三个价值:① ⭐ 逻辑可以就近定义(不必像 Redux/Zustand 那样「把逻辑放进 store 定义文件」,甚至能和 UI 组件放在一起);② ⭐ 天然拿到最新 state(get(x)总是读数当前值,不需要担心闭包陷阱,见 2.13);③ ⭐ 配合useSetAtom能拿到「零重渲染」的组件——「触发操作的组件」完全不订阅值,所以「值怎么变都不重渲染」。典型用法:addToCartAtom、submitFormAtom、toggleThemeAtom(把「跨多个 atom 的一次性修改」包成一个原子操作,避免「中间态可见」)。
一句话速记
Jotai 是「自下而上的原子化状态」——atom 是最小单元,派生 atom 用
atom((get) => ...)表达,依赖在「读取时自动追踪」(锚点 = 当前计算的 atom,建立deps/dependents双向边),写入时只「标脏 + 沿 dependents 传播」,重算发生在「有人读」时(⭐ 懒计算 + 缓存);⭐ 两个实现关键:重算前必须清空旧依赖(否则依赖只增不减)、写入不立刻重算;⭐useAtomValue(只读)/useSetAtom(只写 → **值变化完全不重渲染)/useAtom(读写)** 的分离是性能关键(显示用前者、操作用后者);原子粒度按「变化频率 + 消费范围」切分(一起变的放一起),太粗会全量重渲染、太细难维护,⭐ 实用的中间方案是「对象 atom + 字段级派生 atom」;⚠️ 派生 atom 返回新对象会触发无谓/无限重渲染(getSnapshot必须稳定);Provider 用于「多实例 / SSR 隔离 / 测试隔离」(⚠️ store 不能每次渲染新建);可写派生 atom 让「业务操作」成为可组合的单元(逻辑就近、天然读最新值、配useSetAtom零重渲染)。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!