昨天我們用選擇器告訴 React 元件在乎哪一塊,ThemeLabel 從此不再跟著購物車重新渲染。但 store 的通知方式沒有變:每一次寫入,每個選擇器還是會跑一次,而「跟我有沒有關」的判斷,要靠呼叫端先把在乎的那一塊寫成函式。
但有些值不是直接從 store 算出來,而是從另一個選擇器算出來的。
一旦選擇器開始依賴選擇器,store 還看得見發生了什麼嗎?
遠端世界 ✓ · 共享狀態 ✍️ · 使用者輸入 · 瀏覽器 API · 互動行為 · 真實 DOM
頁面上常有一些值不是直接存著的,而是從別的值算出來的。例如結帳區塊上的字:購物車有東西就寫「去結帳」,沒有就寫「購物車是空的」。而「購物車有沒有東西」本身,也是從購物車數量算出來的。
沿用昨天的 Shop、shopStore 與 useSlice,我們把這兩層各寫成一個選擇器,讓後者呼叫前者,並在兩個選擇器裡各放一行 console.log,記錄它們什麼時候被呼叫:
const selectHasItems = (shop: Shop): boolean => {
console.log("selectHasItems 被呼叫");
return shop.cartCount > 0;
};
const selectCheckoutLabel = (shop: Shop): string => {
console.log("selectCheckoutLabel 被呼叫");
return selectHasItems(shop) ? "去結帳" : "購物車是空的";
};
const CheckoutLabel = () => {
const label = useSlice(shopStore, selectCheckoutLabel);
console.log("CheckoutLabel 渲染");
return <p>{label}</p>;
};
export const App = () => (
<div>
<CheckoutLabel />
<button
onClick={() => shopStore.setState((s) => ({ ...s, userName: "Leon" }))}
>
改名字
</button>
<button
onClick={() =>
shopStore.setState((s) => ({ ...s, cartCount: s.cartCount + 1 }))
}
>
加一件
</button>
</div>
);
先按一次「加一件」,讓購物車裡有東西。接著再按一次,購物車從 1 變成 2,主控台印出:
selectCheckoutLabel 被呼叫
selectHasItems 被呼叫
CheckoutLabel 沒有重新渲染,代表昨天的切片仍然有效。但兩個選擇器還是跑了一遍。
然後,點擊「改名字」,我們會發現,主控台還是會印出這兩行。改名字跟購物車無關,它們照樣被呼叫;selectHasItems 算出來的仍然是 true,selectCheckoutLabel 還是接著把標籤算了一次。
那麼,明明中間那一層沒有變,為什麼後面那一層停不下來?
因為「selectCheckoutLabel 依賴 selectHasItems」這件事,只寫在它的函式本體裡。
store 看得到的是一整包 Shop 與一組聽眾,React 看得到的是 selectCheckoutLabel 交回來的那個字串。但選擇器裡面又呼叫了誰,這兩個地方都看不出來。
這不是選擇器跑太多次的問題,而是值與值之間已經有一張圖,只是這張圖寫在函式裡,store 看不見它。
如果把這張圖交給 store 保管,讓圖上的每個值各自成為一個可以被訂閱的單位,一次寫入能不能只沿著它,走到真的需要重算的地方?
要讓 store 看見這張圖,得先讓圖上的每個點都是 store 認得的東西。我們把這種點稱為節點 (atom)。
節點有兩種。帶一個初始值的是來源節點;帶一個函式的是衍生節點,它在函式裡用 get 讀了誰,誰就是它的依賴:
const cartCountAtom = atom(0);
const userNameAtom = atom("訪客");
const hasItemsAtom = atom((get) => get(cartCountAtom) > 0);
const checkoutLabelAtom = atom((get) =>
get(hasItemsAtom) ? "去結帳" : "購物車是空的",
);
如果將其畫成圖,大概是這樣:
cartCountAtom
│
▼
hasItemsAtom
│
▼
checkoutLabelAtom
userNameAtom
atom 本身不存值,它只是一份描述:來源節點記著初始值,衍生節點記著那個函式。值由 store 保管。
接著是 store。我們在 Day 08 裡寫的 store 只保管一個值,所以讀、寫、訂閱都不必說明對象是誰。現在它要保管許多個值,於是每個動作都多指名一個節點:
// Day 08:一個 store,一個值
shopStore.getState();
shopStore.setState(next);
shopStore.subscribe(listener);
// 今天:一個 store,許多節點
store.get(checkoutLabelAtom);
store.set(cartCountAtom, next);
store.sub(checkoutLabelAtom, listener);
Day 08 那個 let state 與那一組 listeners,也跟著拆到每個節點身上。每個節點在 store 裡都有一份自己的紀錄。以 hasItemsAtom 為例,購物車從 0 變成 1 之後,它的紀錄長這樣:
hasItemsAtom
value true
version 1
deps cartCountAtom(讀到的是第 1 版)
dependents checkoutLabelAtom
listeners (沒有人直接訂閱它)
value 與 listeners 是從地基拆下來的,一個是值,一個是聽眾。多出來的三格,是為了回答一個地基從來不需要回答的問題:寫了這個節點,還有誰跟著變?
version:值真的變了,才加一。hasItemsAtom 從 false 變成 true,所以是第 1 版。deps:上一次算的時候讀了哪些節點,以及當時讀到的是第幾版。dependents:反向的邊,記著誰上一次算的時候讀過我。deps 往上看,看「我依賴誰」;dependents 往下看,看「誰依賴我」。
在程式碼裡,這份紀錄叫做 AtomState。store 以節點本身當鑰匙去查它:stateOf 負責查,查不到就回傳 null;createState 負責建一份新的。
後面的程式碼裡,節點的型別寫作 Atom,其中來源節點寫作 PrimitiveAtom。
衍生節點的值由 compute 算出來。它交給 read 的 get 不只是讀值,還會記下讀了誰、當時是第幾版:
const compute = <T>(
atom: Atom<T>,
previous: AtomState<T> | null,
): AtomState<T> => {
const deps = new Map<AnyAtom, number>();
const value = atom.read((dep) => {
const depState = readState(dep);
deps.set(dep, depState.version);
return depState.value;
});
算完之後,依照這一次實際讀到的節點更新反向的邊,這一次沒再讀的拆掉,新讀到的補上:
for (const dep of previous?.deps.keys() ?? []) {
if (!deps.has(dep)) stateOf(dep)?.dependents.delete(atom);
}
for (const dep of deps.keys()) stateOf(dep)?.dependents.add(atom);
這裡沒有依賴陣列。圖長什麼樣,取決於上一次 read 實際 get 了哪幾個節點。
最後是版本號。值用 Object.is 比對過確實變了,版本才加一:
if (previous === null) return createState(atom, value, deps);
previous.deps = deps;
if (!Object.is(previous.value, value)) {
previous.value = value;
previous.version += 1;
}
return previous;
};
有了版本號,讀一個衍生節點時,就可以先看它記下的每個依賴,版本是不是都跟當時一樣。一樣就沿用上一次的值,不一樣才重新算:
const isFresh = (state: AtomState<unknown>): boolean =>
[...state.deps].every(([dep, version]) =>
Object.is(readState(dep).version, version),
);
const readState = <T>(atom: Atom<T>): AtomState<T> => {
const state = stateOf(atom);
if (isPrimitive(atom)) {
return state ?? createState<T>(atom, atom.init as T, new Map());
}
if (state !== null && isFresh(state)) return state;
return compute(atom, state);
};
isFresh 在比對版本之前,會先用 readState 把每個依賴讀到最新。所以一個節點重新算的時候,它讀到的依賴都已經是新的。
set 只收來源節點。衍生節點的值是算出來的,這份 store 不讓它被直接寫入。
set 的開頭跟 Day 08 的 setState 一樣,先寫來源節點,值沒變就直接回去:
const set = <T>(atom: PrimitiveAtom<T>, next: T | ((prev: T) => T)): void => {
const state = readState(atom);
const value = isUpdater(next) ? next(state.value) : next;
if (Object.is(value, state.value)) return;
state.value = value;
state.version += 1;
接下來要找出這次寫入可能影響到誰。從來源節點出發,沿著 dependents 一路往下走,走得到的節點才有機會變:
const reachableDependents = (source: AnyAtom): Set<AnyAtom> => {
const reached = new Set<AnyAtom>();
const visit = (atom: AnyAtom): void => {
for (const dependent of stateOf(atom)?.dependents ?? []) {
if (reached.has(dependent)) continue;
reached.add(dependent);
visit(dependent);
}
};
visit(source);
return reached;
};
然後分三步:先把這些節點現在的版本全部記下來,再逐一讀過一次,最後只叫醒版本動了的那幾個節點的聽眾:
const before = new Map<AnyAtom, number | null>();
for (const dependent of reachableDependents(atom)) {
before.set(dependent, stateOf(dependent)?.version ?? null);
}
const changed: AnyAtom[] = [atom];
for (const [dependent, version] of before) {
if (!Object.is(readState(dependent).version, version)) {
changed.push(dependent);
}
}
for (const changedAtom of changed) {
for (const listener of [...(stateOf(changedAtom)?.listeners ?? [])])
listener();
}
};
「逐一讀過」用的就是剛才的 readState,依賴的版本都沒動,就不會重算。
最後是 sub。它跟 Day 08 的 subscribe 幾乎一樣,只是聽眾掛在節點上。它先讀一次,是因為反向的邊要在算的時候才會畫上去,沒算過的節點,傳播走不到它:
const sub = (atom: AnyAtom, listener: Listener): (() => void) => {
const state = readState(atom);
state.listeners.add(listener);
return (): void => {
state.listeners.delete(listener);
};
};
💡 這裡的
createStore是為了看清楚「傳播範圍由誰算」的實驗,不是 Jotai 的實作,也不是 production-ready 的實作。Jotai 只在有人訂閱、或被已訂閱節點依賴的節點之間維護反向的邊,其餘的衍生節點等到下次被讀時才重算;這裡算過的節點都留著邊。非同步的節點、節點之間的循環依賴,這裡都沒有處理。
接上 React 的部分,跟 Day 08 的 useStore、昨天的 useSlice 是同一行 useSyncExternalStore:
import { useCallback, useSyncExternalStore } from "react";
export function useAtomValue<T>(store: Store, atom: Atom<T>): T {
const subscribe = useCallback(
(listener: Listener): (() => void) => store.sub(atom, listener),
[store, atom],
);
const getSnapshot = useCallback((): T => store.get(atom), [store, atom]);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
把三天的那一行並排:
// Day 08:useStore
useSyncExternalStore(store.subscribe, store.getState, store.getState);
// Day 09:useSlice
useSyncExternalStore(store.subscribe, getSlice, getSlice);
// Day 10:useAtomValue
useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
昨天改的是第二個引數:通知照舊發給每個人,每個人交回自己在乎的那一塊。今天改的是第一個引數:從一開始,就只聽一個節點。
用節點重寫開頭那兩層,同樣各放一行 console.log,再加一個讀使用者名稱的 Greeting:
const store = createStore();
const cartCountAtom = atom(0);
const userNameAtom = atom("訪客");
const hasItemsAtom = atom((get) => {
console.log("hasItemsAtom 重算");
return get(cartCountAtom) > 0;
});
const checkoutLabelAtom = atom((get) => {
console.log("checkoutLabelAtom 重算");
return get(hasItemsAtom) ? "去結帳" : "購物車是空的";
});
const Greeting = () => {
const userName = useAtomValue(store, userNameAtom);
console.log("Greeting 渲染");
return <p>{userName},你好</p>;
};
const CheckoutLabel = () => {
const label = useAtomValue(store, checkoutLabelAtom);
console.log("CheckoutLabel 渲染");
return <p>{label}</p>;
};
export const App = () => (
<div>
<Greeting />
<CheckoutLabel />
<button onClick={() => store.set(userNameAtom, "Leon")}>改名字</button>
<button onClick={() => store.set(cartCountAtom, (c) => c + 1)}>
加一件
</button>
</div>
);
按下「改名字」,主控台只印出一行:
Greeting 渲染
按下「加一件」,購物車從 0 變成 1:
hasItemsAtom 重算
checkoutLabelAtom 重算
CheckoutLabel 渲染
再按一次,購物車從 1 變成 2:
hasItemsAtom 重算
跟開頭的選擇器版本放在一起:
| 按鈕 | 選擇器版被呼叫的 | 節點版重算的 |
|---|---|---|
| 改名字 | selectCheckoutLabel、selectHasItems |
無 |
| 加一件(從 1 變成 2) | selectCheckoutLabel、selectHasItems |
hasItemsAtom |
改名字的時候,Greeting 訂閱的就是 userNameAtom 本身,而沒有任何衍生節點讀過它。從它出發沒有邊可以走,hasItemsAtom 與 checkoutLabelAtom 因此連算都沒有算。
那麼,購物車從 1 變成 2 的時候,checkoutLabelAtom 明明在同一條路上,為什麼沒有跟著重算?
hasItemsAtom 在路上,所以被重算了;但它算出來的仍然是 true,版本沒有加一。輪到 checkoutLabelAtom 時,它記下的依賴版本一個都沒動,isFresh 讓它直接沿用上一次的值。
失效 (invalidation) 沿著邊往下走,走到值沒變的節點就停下來。
傳播到哪裡,是由圖的形狀算出來的,而不是把每個訂閱者都問過一遍。
昨天的 selectHeader 每次都組一個新物件,交給 useSlice 之後,元件一掛載就停不下來。同樣的形狀寫成衍生節點:
type Theme = "light" | "dark";
const themeAtom = atom<Theme>("light");
const headerAtom = atom((get): { cartCount: number; theme: Theme } => ({
cartCount: get(cartCountAtom),
theme: get(themeAtom),
}));
const Header = () => {
const header = useAtomValue(store, headerAtom);
console.log("Header 渲染");
return (
<p>
{header.theme}:{header.cartCount}
</p>
);
};
把 App 裡的兩個元件換成 Header。掛載之後只渲染一次,沒有警告;按下「改名字」,主控台沒有新的輸出;按下「加一件」,印出一行:
Header 渲染
畫面上變成 light:1。
同一個形狀,這裡安定下來的原因在 getSnapshot。它交回的是 store 替 headerAtom 保管的那個值,而那個值只有在依賴的版本動了、重新算過之後,才會換成新物件。React 渲染時讀一次、提交前再讀一次,兩次拿到的是同一個物件。
「變了沒」在寫入的那一刻,已經由 store 在每個節點上用 Object.is 比過了。
不過,比較也就只有 Object.is 這一種。如果衍生節點依賴的值變了,算出來的卻是內容相同的新物件,store 還是會把它當成變了:
const badgeAtom = atom((get): { visible: boolean } => ({
visible: get(cartCountAtom) > 0,
}));
const Badge = () => {
const badge = useAtomValue(store, badgeAtom);
console.log("Badge 渲染");
return badge.visible ? <span>●</span> : null;
};
把 App 裡的元件換成 Badge,按兩次「加一件」。第二次購物車從 1 變成 2,visible 還是 true,但 badgeAtom 交回的是另一個物件,主控台印出:
Badge 渲染
在這份 store 裡,沒有地方放一個比 Object.is 寬鬆的比較函式。能動的是圖本身。把 badgeAtom 改成讀 hasItemsAtom,而不是直接讀 cartCountAtom:
const badgeAtom = atom((get): { visible: boolean } => ({
visible: get(hasItemsAtom),
}));
購物車從 1 變成 2,hasItemsAtom 的值沒變,失效在它那裡就停了,badgeAtom 不會重算,Badge 也就不會重新渲染。
昨天,「什麼算變了」由呼叫端寫一個比較函式來回答。在這裡,它比較像是由節點切在哪裡來回答。
💡 Jotai 在
jotai/utils裡提供了selectAtom,可以替衍生出來的節點帶一個比較函式,預設值是Object.is。這裡沒有臨摹它。
set 裡的第一步「先把版本全部記下來」,是後來才補上的。我第一版是走到哪個節點,才記它的版本、接著讀它。前面的例子都照常運作,直到遇上這樣一張圖:
const a = atom(1);
const p = atom((get) => get(a) + 1);
const c = atom((get) => get(a) * 2);
const d = atom((get) => get(p) + get(c));
a
/ \
p c
\ /
d
三個衍生節點各被訂閱一次,然後把 a 從 1 改成 2。p、c、d 算出來的值都對,但 c 的聽眾一次都沒有被叫醒。
值是對的,通知是錯的。
問題出在走訪的順序。從 a 出發,走到的順序是 p、d、c。輪到 d 的時候,它要讀 c,readState 便當場把 c 重算,版本加一。等迴圈走到 c 才去記它的版本,記到的已經是新的那一個,比對下來就成了沒變。
也就是說,走訪的順序決定了一個節點在被記下版本之前,會不會先被別人順手算好。
當時我的測試全部是綠的,這個錯是在 code review 時才被抓出來。
改法有兩種:
兩種改法回答的是同一個問題:一次寫入之後,store 要怎麼知道誰真的變了。
回到今天開頭的那兩層。選擇器版本裡,selectCheckoutLabel 依賴 selectHasItems,這件事只有函式本體知道,所以 store 每次都得問遍所有人。換成節點之後,store 看得見這張圖,寫入只沿著邊走,走到值沒變的節點就停。
不過,那張圖是誰畫的?
cartCountAtom、userNameAtom 是我們把 Shop 拆開來的;hasItemsAtom 讀 cartCountAtom、checkoutLabelAtom 讀 hasItemsAtom,是我們在 read 裡一個一個 get 出來的;讓 Badge 不再多渲染一次,也是我們把它改接到 hasItemsAtom 上。store 從頭到尾只是沿著我們畫好的邊走。
昨天,依賴寫在選擇器裡,寫成「我要這一塊」;今天,依賴寫在節點裡,寫成「我讀了這幾個」。
store 終於看得見依賴了。但依賴本身,還是我們親手告訴它的。可是,憑什麼?
本文是以
zustand5.0.15、jotai2.20.3、valtio2.3.2 為基礎,於 2026-08-31 檢閱;Jotai 寫入之後的重算順序(recomputeInvalidatedAtoms)與selectAtom的預設比較,於 2026-09-25 查核;實驗跑在react19.3.0 與react-dom19.3.0,於 2026-09-25 進行。