過去兩天,不管是選擇器還是節點,我們都是先把依賴寫下來。store 不會自己知道誰在乎什麼,所以呼叫端需要先告訴它。
可是 Valtio 看起來不是這樣。
它既沒有使用選擇器,也沒有用上節點。如果呼叫端什麼都不寫,store 還有辦法知道誰該被通知嗎?
今天是探索共享狀態的最後一天,就讓我們透過 Valtio,來看看依賴是不是一定要被描述。
遠端世界 ✓ · 共享狀態 ✓ · 使用者輸入 · 瀏覽器 API · 互動行為 · 真實 DOM
沿用昨天那一家店,不過今天我們把狀態交給 proxy 管理。每次寫入就是一次賦值;元件用 useSnapshot 讀它,沒有選擇器,也沒有節點。兩個元件各放一行 console.log,記錄它們什麼時候重新渲染:
interface Shop {
cartCount: number;
theme: "light" | "dark";
userName: string;
}
const shop = proxy<Shop>({ cartCount: 0, theme: "light", userName: "訪客" });
const CartBadge = () => {
const snap = useSnapshot(shop);
console.log("CartBadge 渲染");
return <p>購物車:{snap.cartCount}</p>;
};
const Greeting = () => {
const snap = useSnapshot(shop);
console.log("Greeting 渲染");
return <p>{snap.userName},你好</p>;
};
export const App = () => (
<div>
<CartBadge />
<Greeting />
<button
onClick={() => {
shop.cartCount += 1;
}}
>
加一件
</button>
<button
onClick={() => {
shop.userName = "Leon";
}}
>
改名字
</button>
</div>
);
按下「加一件」,主控台印出:
CartBadge 渲染
按下「改名字」,主控台印出:
Greeting 渲染
結果跟 Day 09 的選擇器、Day 10 的節點一樣,沒有人被多叫醒一次。
差別在呼叫端。今天用的 useSnapshot(shop) Hook,在兩個元件的寫法都一樣,而不需要購物車資料的 Greeting 裡,並沒有任何一個地方寫著「我不在乎購物車」。
那麼,它是怎麼知道的?
讓我們回到這兩個元件上,CartBadge 最後只讀取 snap.cartCount,而 Greeting 則是只有讀 snap.userName。
如果「讀了哪裡」本身就是依賴,呼叫端就不必另外把它寫下來。
這樣一來,依賴不再是呼叫端交給 store 的一份描述,而是元件渲染時留下來的紀錄。
那麼,這份紀錄要怎麼留下來?又要在什麼時候拿出來比?
讓我們先看看寫入那一端。
前三天,寫入都要呼叫一個函式:setState、set。今天呼叫端只寫 shop.cartCount += 1,這是一行普通的賦值,store 要怎麼知道它發生了?
JavaScript 內建的 Proxy 做的就是這件事。new Proxy(target, handler) 會交回一個看起來跟 target 一樣的物件,但對它的每一次讀取與寫入,都會先經過 handler 裡的 get 與 set。我們可以先用一個最小的例子看它攔下了什麼:
const target = { cartCount: 0 };
const watched = new Proxy(target, {
get(target, key) {
console.log(`讀了 ${String(key)}`);
return Reflect.get(target, key);
},
set(target, key, value) {
console.log(`寫了 ${String(key)}:${value}`);
return Reflect.set(target, key, value);
},
});
watched.cartCount += 1;
執行之後,主控台印出:
讀了 cartCount
寫了 cartCount:1
一行 +=,其實是先讀一次、再寫一次,兩次都被攔下來了。呼叫端的寫法完全沒變,攔截發生在物件這一層。
用 Object.defineProperty 替每個欄位各定義一組 getter 與 setter,也能做到類似的事,但得事先知道有哪些欄位。Proxy 不必,它攔的是整個物件。
這一篇會用到它兩次:寫入那一端攔 set,讀取那一端攔 get。
先看攔 set 的那一個。proxy 底下是我們在 Day 08 寫的 createStore,一行都沒有改。它只做一件事:把攔下來的賦值,翻譯成一次 setState:
export function proxy<T extends object>(initialState: T): T {
const store = createStore<T>({ ...initialState });
const state = new Proxy(initialState, {
get(_target, key) {
return Reflect.get(store.getState(), key);
},
set(target, key, value) {
if (!Object.hasOwn(target, key)) return false;
store.setState((prev) =>
Object.is(Reflect.get(prev, key), value)
? prev
: { ...prev, [key]: value },
);
return true;
},
});
stores.set(state, store);
return state;
}
get 一律去讀 store 目前那一份狀態;set 則是每一次寫入都組一個新物件交給 setState,所以 getState() 交回的那一份,之後不會再被任何人修改。它是某一刻的狀態,被固定下來,也就是一份快照 (snapshot)。
直接改同一個物件,看起來比較省事。之所以每次都換一份,是因為接下來有三個地方,都需要「上一刻」還留著:
useSyncExternalStore 用 Object.is 比較 getSnapshot 交回的值,來決定要不要重新渲染。一直改同一個物件,身分永遠不變,React 看不出它變了。isChanged 要拿上一份與這一份逐欄比較。原地修改的話,上一份的值早就被蓋掉,沒有東西可以比。只記一個版本號也不夠。版本號說得出「有東西變了」,卻說不出變的是哪一個欄位,而今天要的正是後者。
寫入相同的值時,set 交回原本那一份,地基的 setState 看到身分沒變,就不會通知聽眾。
最後一行把代理與它背後的 store 記進一個叫 stores 的 WeakMap,讀的那一端要靠它找回 store。
這一端完全不判斷誰該被通知。值變了,它就像 Day 08 一樣,通知所有訂閱它的元件。
所以判斷只能落在讀的那一端。
useSnapshot 交給元件的不是快照本身,而是包在快照外面的另一層代理。這次攔的是 get:元件渲染時每讀一個欄位,就記下一筆。
記下來的東西,放在一個型別叫 Affected 的 WeakMap 裡:
export type Affected = WeakMap<object, Set<PropertyKey>>;
鍵是一份快照,值是這份快照被讀過的欄位。後面說的「帳」,指的就是它。
鍵之所以是快照,是因為每一次寫入都會換一份新的,而等一下要問的是「上一份畫出來的快照,被讀過哪些欄位」。帳得跟著快照走。
用 WeakMap 而不用 Map,是因為舊快照會一份一份累積下來。Map 會一直拿著它的鍵,舊快照就會一直留在記憶體裡;WeakMap 不會,一份快照不再被任何人參考時,它連同帳上那一筆可以一起被垃圾回收,不必自己清。前面的 stores 用 WeakMap 也是同一個理由:代理沒有人用了,背後的 store 就跟著放掉。
track 負責包上這層代理,每讀一次,就往那一份快照的帳上加一個欄位:
export function track<T extends object>(snapshot: T, affected: Affected): T {
return new Proxy(snapshot, {
get(target, key) {
const used = affected.get(target) ?? new Set<PropertyKey>();
used.add(key);
affected.set(target, used);
return Reflect.get(target, key);
},
});
}
Greeting 渲染一次之後,它的帳大概是這樣:
affected
第 1 份快照 → { userName }
有了這份帳,下一次有寫入時,就可以只比上一份快照被讀過的那幾個欄位:
export function isChanged<T extends object>(
prev: T,
next: T,
affected: Affected,
): boolean {
if (Object.is(prev, next)) return false;
const used = affected.get(prev);
if (used === undefined) return true;
return [...used].some(
(key) => !Object.is(Reflect.get(prev, key), Reflect.get(next, key)),
);
}
按下「加一件」之後,store 換成了第 2 份快照,cartCount 從 0 變成 1。可是 Greeting 的帳上只有 userName,兩份快照的 userName 都是「訪客」,所以對它來說,這次寫入沒有變。
一個欄位都沒讀過的快照無從比起,一律當成變了。
接上 React 的部分,跟前三天一樣是一行 useSyncExternalStore:
import { useLayoutEffect, useMemo, useRef, useSyncExternalStore } from "react";
export function useSnapshot<T extends object>(state: T): Readonly<T> {
const store = storeOf(state);
const affected = useMemo<Affected>(() => new WeakMap(), [state]);
const lastSnapshot = useRef<T | null>(null);
let inRender = true;
const getSnapshot = (): T => {
const next = store.getState();
const last = lastSnapshot.current;
if (!inRender && last !== null && !isChanged(last, next, affected)) {
return last;
}
return next;
};
const snapshot = useSyncExternalStore(
store.subscribe,
getSnapshot,
store.getState,
);
inRender = false;
useLayoutEffect(() => {
lastSnapshot.current = snapshot;
});
return track(snapshot, affected);
}
storeOf 就是從剛才那個 WeakMap 找回代理背後的 store。lastSnapshot 記的是上一份真的畫到畫面上的快照,所以在提交之後的 useLayoutEffect 裡才更新。
把這四天使用的 Hook 並排:
// 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);
// Day 11:useSnapshot
useSyncExternalStore(store.subscribe, getSnapshot, store.getState);
Day 09 改的是第二個引數,Day 10 改的是第一個。今天第一個引數沒有動,還是聽整個 store;改的又是第二個。
不過,Day 09 的第二個引數交回什麼,是呼叫端寫的選擇器決定的。今天的 getSnapshot 不收任何東西,它自己拿上一份快照的帳去比:被讀過的欄位都沒變,就交回上一份,React 看到同一個身分,便不會重新渲染。
💡 這裡的
proxy與useSnapshot是為了看清楚「依賴由誰決定」的實驗,並不是 Valtio 的實作,也不是 production-ready 的實作。Valtio 預設會把同一輪的多次寫入收成一次通知;它的追蹤代理有快取,同一份快照包出來的代理身分不變;它也追蹤巢狀物件、in與Object.keys。這裡都沒有處理。
getSnapshot 裡有一個條件還沒解釋:inRender。渲染中呼叫 getSnapshot 時,它不看帳,一律交回最新的快照;只有渲染之外,也就是寫入之後 React 來問「要不要重新渲染」的時候,才拿帳去比。
這個條件,我一開始照著 Valtio 的原始碼寫進來,然後逐一拿掉實作裡的判斷,看測試會不會轉紅,拿掉它的時候,測試仍然全綠。
既然帳上記著被讀過的欄位,渲染中拿來比一次,看起來也沒有什麼不對。那麼,它到底擋住了什麼?
我用一個會收合的面板,把每一次渲染畫出的內容都印出來。面板收合時只讀標題,展開時才讀內容:
const panel = proxy({ title: "標題", detail: "第一版" });
const Panel = ({ expanded }: { expanded: boolean }) => {
const snap = useSnapshot(panel);
const text = `${snap.title}/${expanded ? snap.detail : "收合"}`;
console.log(text);
return <p>{text}</p>;
};
export const App = () => {
const [expanded, setExpanded] = useState(false);
return (
<div>
<Panel expanded={expanded} />
<button
onClick={() => {
panel.detail = "第二版";
}}
>
改內容
</button>
<button onClick={() => setExpanded(true)}>展開</button>
</div>
);
};
掛載時印出「標題/收合」。按下「改內容」,主控台沒有新的輸出,因為收合時沒有讀 detail,這符合預期。
接著按下「展開」,拿掉 inRender 的版本印出:
標題/第一版
標題/第二版
detail 早就是「第二版」了,畫面卻先畫出一次「第一版」。
原因在帳是怎麼長出來的。上一份快照的帳上只有 title,因為收合時只讀了它。展開的那一次渲染,拿這份帳去比,title 沒變,於是交回舊快照;元件從舊快照讀到了「第一版」,也在這時才第一次把 detail 記進帳裡。提交之後,React 再問一次,這次帳上有 detail 了,一比發現變了,再畫一次。
這一次渲染會讀哪些欄位,要畫完才知道。
所以拿上一次的帳來判斷這一次渲染,等於用上一次的依賴回答這一次的問題。放回 inRender 之後,展開只印出一行:
標題/第二版
這也解釋了測試為什麼沒有轉紅。兩個版本最後停在畫面上的,都是「標題/第二版」,而我原本的那條測試只檢查最後的畫面。錯的那一次渲染夾在中間,提交之後馬上就被蓋掉了,要把每一次畫了什麼都記下來才看得到。
回到同一家店,把三天的讀數放在一起。選擇器與節點的那兩欄,是 Day 09 與 Day 10 量過的結果:
| 寫法 | 選擇器(Day 09) | 節點(Day 10) | 讀取軌跡(今天) |
|---|---|---|---|
| 改購物車,只讀主題的元件 | 不渲染 | 不渲染 | 不渲染 |
每次組一個新物件(selectHeader 的形狀) |
掛載時停不下來 | 安定 | 安定 |
| 讀「購物車有沒有東西」,購物車從 1 變成 2 | 不渲染 | 不渲染 | 重新渲染 |
| 呼叫端要寫的東西 | 選擇器(+比較函式) | 節點 | 無 |
第二列的讀取軌跡版本,是把 selectHeader 那個物件改成在渲染裡組:
const Header = () => {
const snap = useSnapshot(shop);
const header = { cartCount: snap.cartCount, theme: snap.theme };
console.log("Header 渲染");
return (
<p>
{header.theme}:{header.cartCount}
</p>
);
};
把 App 裡的兩個元件換成 Header。掛載之後只渲染一次,按下「改名字」沒有新的輸出,按下「加一件」印出一行「Header 渲染」,畫面變成 light:1。物件每次都是新的,但它不在 getSnapshot 裡,React 比的仍然是快照的身分。
第三列要看的是 Day 10 開頭那個結帳標籤:
const CheckoutLabel = () => {
const snap = useSnapshot(shop);
console.log("CheckoutLabel 渲染");
return <p>{snap.cartCount > 0 ? "去結帳" : "購物車是空的"}</p>;
};
把 App 裡的元件換成 CheckoutLabel。先按一次「加一件」,再按一次,購物車從 1 變成 2,主控台印出:
CheckoutLabel 渲染
畫面上還是「去結帳」,它卻重新渲染了一次。選擇器與節點在這裡都停住了,這一版沒有。
那麼,同一件事,為什麼這一版停不下來?
因為 CheckoutLabel 讀的是 cartCount,帳上記的也是 cartCount,而 cartCount 確實從 1 變成了 2。至於元件在渲染裡從它算出了一個 true,那不在帳上。選擇器與節點比的,是算出來的那個值;這裡比的,是被讀過的欄位。
比較的單位,就是訂閱的單位。
第二列與第三列其實是同一件事的兩面。因為比較只落在欄位上,在渲染裡組新物件不會出事;也因為比較只落在欄位上,從欄位算出來的值沒變時,它沒有辦法停下來。
既然依賴是渲染時留下的紀錄,那它會不會因為寫法不同而改變形狀?
先看兩個畫出一模一樣東西的元件。一個只解構出 cartCount,另一個把整份快照展開成一個新物件:
const Picked = () => {
const { cartCount } = useSnapshot(shop);
console.log("Picked 渲染");
return <p>購物車:{cartCount}</p>;
};
const Spread = () => {
const view = { ...useSnapshot(shop) };
console.log("Spread 渲染");
return <p>購物車:{view.cartCount}</p>;
};
把 App 裡的兩個元件換成 Picked 與 Spread,按下「改名字」,主控台印出:
Spread 渲染
展開會把每個欄位都讀過一遍,而每一次讀都被記進帳裡。兩個元件在畫面上的差別是零,訂閱卻差了兩個欄位。
再看另一種寫法:把快照傳給子元件,由子元件去讀內容。
const Detail = ({ snap }: { snap: { detail: string } }) => {
console.log("Detail 渲染");
return <p>{snap.detail}</p>;
};
const Card = () => {
const snap = useSnapshot(panel);
console.log("Card 渲染");
return (
<section>
<h2>{snap.title}</h2>
<Detail snap={snap} />
</section>
);
};
把收合面板那個 App 裡的 Panel 換成 Card,按下「改內容」,主控台印出:
Card 渲染
Detail 渲染
Card 自己只讀了 title,detail 一次都沒有出現在它的程式碼裡,被叫醒的卻是它。因為 Detail 讀的那一份代理是 Card 的 useSnapshot 交出去的,讀到的欄位也就記在 Card 的帳上。
這兩個例子裡,沒有哪一行程式碼寫錯。只是訂閱不在程式碼裡,而在渲染實際走過的那條路上;差一個解構的寫法、多傳一次 props,它就換了形狀。
選擇器版本的訂閱,寫在那個選擇器函式裡;節點版本的訂閱,寫在 read 裡 get 了誰。它們都可以指著某一行說「就是這裡」。讀取軌跡版本沒有那一行可以指。
四天下來,我們的每一份臨摹都接在同一行 useSyncExternalStore 上。那麼,被臨摹的函式庫本身也是這樣接的嗎?
Jotai 預設的 useAtomValue 不是,2.20.3 與 3.0.0 都一樣。它在 useEffect 裡用 store.sub 掛上聽眾,再透過 useReducer 觸發重新渲染,形狀比較接近 Day 08 的 useNaiveStore。
只看到這裡,很容易讀成「它還沒有跟上」。但 3.0.0 發布時,另外公開了一個用 useSyncExternalStore 寫成的 useAtomValueRawSync,而遷移指南寫明,useAtomValue 仍然是預設,換用新的 Hook 不是遷移的必要步驟。
依照官方文件,兩條路守住的東西不一樣。useAtomValueRawSync 不會撕裂,也接得住掛載期間寫入的值,代價是更新一律同步渲染,用不上並行渲染 (concurrent rendering);預設的那一條保留了並行渲染,代價是初次渲染到訂閱完成之間的變動,可能晚一步才被接住。
Jotai 並不是排斥 useSyncExternalStore,而是在不同的一致性保證之間,探索該怎麼折衷。
把這四天的三份臨摹放在一起,它們在多數場景上的行為是一樣的:都不撕裂,也都只叫醒該叫醒的元件。差別不在做不做得到,在那件事寫在誰的程式碼裡:
| 判準 | 選擇器 | 節點 | 讀取軌跡 |
|---|---|---|---|
| 訂閱的單位 | 一個 store + 一個選擇器 | 一個節點 | 一次讀取 |
| 切片粒度 | 呼叫端 | 呼叫端 | 函式庫 |
| 傳播範圍 | 呼叫端 | 函式庫 | 函式庫 |
| 相等性比較 | 呼叫端 | 函式庫 | 函式庫 |
用 Day 02 的詞來說,三份答案藏起來的東西一份比一份多。
Zustand 那一側藏得最少:什麼算變了,要由呼叫端寫一個比較函式來回答。Jotai 那一側藏起了傳播的路徑:寫入之後會走到哪些節點,由圖算出來,呼叫端不必想。Valtio 那一側連「你依賴了什麼」都替你藏起來了。
藏得越多,呼叫端要寫的越少;而呼叫端要寫的越少,也就越難從程式碼讀出自己的訂閱長什麼樣。前面的 Spread 與 Card 就是這件事。
那麼,這個領域的複雜度,到底是從哪裡長出來的?
回頭看 Day 08 那個 createStore,一個值、一組聽眾、一個通知,連同型別在內三十多行。今天的 proxy 底下也是它,一行都沒有改。所以複雜度不在「把狀態放到 React 外面保管」這件事上。
這四天真正費力的地方,都跟時間有關。Day 08 的撕裂,是一次被切開的渲染剛好碰上一次寫入;今天的 inRender,是因為一次渲染會讀哪些欄位,要等它畫完才知道;Jotai 那兩條路的取捨,談的也是掛載前後那一段空檔,以及渲染能不能被切開。
我們在 Day 03 說過,渲染可以被切開,所以接進 React 的那個通路,還得負責一致性。這四天看到的,是同一件事被放大之後的樣子:
React 的渲染是有時間性的,而外部狀態沒有。
這是一致性的複雜度,不是儲存的複雜度。
也因此,深度不在 store 本身。三份臨摹的 store 都不難寫,難的是訂閱關係怎麼被建立、又怎麼被維護:選擇器每次通知都要重跑一次;節點之間的邊,每次重算都要依照實際讀到的節點重新畫;讀取軌跡的帳,每次渲染都要重新長一次,而且只能在渲染之外拿來比。
最後,還有一件事這四天沒有問過:這份狀態是誰的。三份臨摹都把狀態放在 React 外面的一個 store 裡,它從建立起就一直在,差別全在誰該被通知,所以 Day 04 到 Day 07 的那個問題,在這裡並不是爭點。
再把呼叫端要交出去的東西排一次:Day 09 是一個選擇器函式,必要時再包一層 useShallow;Day 10 是一組節點;今天什麼都不用寫。
用 Day 02 的詞來說,這比較像是介面寬度一路遞減。而介面後面承接的東西,從一次 Object.is,到一張會自己重算的圖,再到一份每次渲染重新長出來的帳,則是一路遞增。
Day 07 的兩份答案,介面寬度相近,深度不同;這裡的三份答案,是介面越來越窄,深度越來越深。
可是承接得越多,就越難從程式碼看出它替我做了什麼決定。
這四天問的都是同一件事:共享狀態變了,哪些人該知道。答案從呼叫端親手寫下依賴,一路走到讀過就算。
不過,這四天裡的每一次寫入,都是按一下按鈕。
如果狀態在每一次擊鍵都會改變呢?
本文以
zustand5.0.15、jotai2.20.3、valtio2.3.2 為基礎,於 2026-08-31 檢閱;Valtio 的useSnapshot複查於 2026-09-26,它依賴的proxy-compare3.0.1 查核於 2026-09-25;Jotai 3.0.0 的useAtomValue、useAtomValueRawSync與遷移指南查核於 2026-09-26;實驗跑在react19.3.0 與react-dom19.3.0,查核於 2026-09-26。