昨天我們用 useSyncExternalStore 接上了一個活在 React 外面的 store,元件從此讀到同一個版本。但每一次寫入,每個讀它的元件都會重新渲染一次,即使畫面上真正變的只有一個數字。
這件事其實不必等到外部 store 才會遇到。今天先從一個更常見的寫法看起。
遠端世界 ✓ · 共享狀態 ✍️ · 使用者輸入 · 瀏覽器 API · 互動行為 · 真實 DOM
要讓幾個元件共用一包狀態,我想很多人第一個想到的會是用 React 內建的 Context API,再加上 useState 來實作。狀態住在 React 裡面,所以昨天的撕裂在這裡不會發生。
假設現在有一間小店,我們希望放進上下文 (context) 的狀態有三格:購物車數量、主題、使用者名稱,可能會這樣實作:
import {
createContext,
useContext,
useState,
type Dispatch,
type ReactNode,
type SetStateAction,
} from "react";
interface Shop {
cartCount: number;
theme: "light" | "dark";
userName: string;
}
const initialShop: Shop = { cartCount: 0, theme: "light", userName: "訪客" };
type ShopContextValue = [Shop, Dispatch<SetStateAction<Shop>>];
const ShopContext = createContext<ShopContextValue | null>(null);
const ShopProvider = ({ children }: { children: ReactNode }) => {
const value = useState(initialShop);
return <ShopContext.Provider value={value}>{children}</ShopContext.Provider>;
};
export function useShop(): ShopContextValue {
const value = useContext(ShopContext);
if (value === null) throw new Error("useShop 必須在 ShopProvider 之內");
return value;
}
接著寫兩個元件,一個只讀購物車數量,一個只讀主題,然後我們在這兩個元件裡放上 console.log 來記錄是否因渲染而重新執行:
const CartBadge = () => {
const [shop, setShop] = useShop();
console.log("CartBadge 渲染");
return (
<button
onClick={() => setShop((s) => ({ ...s, cartCount: s.cartCount + 1 }))}
>
購物車:{shop.cartCount}
</button>
);
};
const ThemeLabel = () => {
const [shop] = useShop();
console.log("ThemeLabel 渲染");
return <p>主題:{shop.theme}</p>;
};
export const App = () => (
<ShopProvider>
<CartBadge />
<ThemeLabel />
</ShopProvider>
);
按一下購物車,主控台印出:
CartBadge 渲染
ThemeLabel 渲染
主題沒有變,ThemeLabel 卻跟著重新渲染了一次。
換成昨天的 useStore,結果也一樣。store 裡裝的是整個 Shop,每次寫入都換成一個新物件,而 ThemeLabel 從 useStore 拿到的正是那整個物件:
const shopStore = createStore(initialShop);
const ThemeLabel = () => {
const shop = useStore(shopStore);
console.log("ThemeLabel 渲染");
return <p>主題:{shop.theme}</p>;
};
兩個版本的狀態住在不同地方,一個在 React 裡面,一個在外面,被叫醒的方式卻一樣。
這不是渲染得太慢,而是元件訂閱的單位是整包:它拿到的是整份狀態,所以不管變的是哪一格,它都分辨不出來。
那麼,要怎麼告訴 React,這個元件在乎的只是其中一格?
昨天的 useStore 把 store.getState 直接交給 useSyncExternalStore 當作 getSnapshot。每次 store 通知,React 會再讀一次 getSnapshot,拿結果和上一次的用 Object.is 比對,不一樣才重新渲染。
由於 getState 回傳的是整個物件,而每次寫入都會換成新物件,所以對每一個讀它的元件來說,每次都是「變了」。
那如果 getSnapshot 交回的不是整包,而是元件在乎的那一格呢?
我們讓呼叫端傳一個函式進來,說明它要哪一塊,這個函式稱為選擇器 (selector),它取出來的那一塊稱為切片 (slice):
import { useCallback, useSyncExternalStore } from "react";
export type Selector<T, U> = (state: T) => U;
export function useSlice<T, U>(store: Store<T>, selector: Selector<T, U>): U {
const getSlice = useCallback(
(): U => selector(store.getState()),
[store, selector],
);
return useSyncExternalStore(store.subscribe, getSlice, getSlice);
}
和昨天的 useStore 相比,多出來的是 selector 這個引數,以及包住它的 getSlice。useCallback 只是避免每次渲染都交給 React 一個新的 getSnapshot。今天真正要觀察的是它回傳什麼值,而不是函式本身的身分。
store 本身一行都沒有改。
💡 這裡的
useSlice是為了看清楚「在乎哪一塊」這件事被放在誰手上的實驗,並不是 production-ready 的實作。伺服器渲染時該交出初始狀態而不是當下的狀態、選擇器本身丟出錯誤,這兩種情況這裡都沒有處理。
用它來重寫那兩個元件,選擇器寫在元件外面,每次渲染都是同一個函式:
const shopStore = createStore(initialShop);
const selectCartCount = (shop: Shop): number => shop.cartCount;
const selectTheme = (shop: Shop): Shop["theme"] => shop.theme;
const CartBadge = () => {
const cartCount = useSlice(shopStore, selectCartCount);
console.log("CartBadge 渲染");
return <p>購物車:{cartCount}</p>;
};
const ThemeLabel = () => {
const theme = useSlice(shopStore, selectTheme);
console.log("ThemeLabel 渲染");
return <p>主題:{theme}</p>;
};
export const App = () => (
<div>
<CartBadge />
<ThemeLabel />
<button
onClick={() =>
shopStore.setState((s) => ({ ...s, cartCount: s.cartCount + 1 }))
}
>
加一件
</button>
</div>
);
按下「加一件」,主控台只印出一行:
CartBadge 渲染
ThemeLabel 的選擇器回傳的是 "light" 這個字串,寫入前後都一樣,Object.is 認為它沒變,所以它沒有被叫醒。
到這裡為止,選擇器回傳的都是字串或數字。可是頁首那種元件,往往同時要好幾格,比如主題與購物車數量。
最自然的寫法,是讓選擇器一次交回一個物件:
const selectHeader = (shop: Shop): Pick<Shop, "cartCount" | "theme"> => ({
cartCount: shop.cartCount,
theme: shop.theme,
});
const Header = () => {
const header = useSlice(shopStore, selectHeader);
console.log("Header 渲染");
return (
<p>
{header.theme}:{header.cartCount}
</p>
);
};
我原本預期的代價是多渲染幾次:使用者名稱變了,Header 也會跟著醒來,因為每次都是新物件。
那麼,實際上會發生什麼事呢?
還沒有按任何按鈕,Header 一掛載,React 就先印出一行警告,接著主控台一行一行地印出「Header 渲染」,最後拋出錯誤:
The result of getSnapshot should be cached to avoid an infinite loop
Error: Maximum update depth exceeded. ...
在我的測試環境(jsdom、React 19.3.0)裡,它在拋錯之前渲染了 54 次。而這段期間,shopStore.setState 一次都沒有被呼叫過。
沒有人寫過 store,是讀它的方式讓它看起來一直在變。
React 看到的不是 store,而是 getSnapshot 的結果。
回頭看 getSnapshot 的比較就說得通了。selectHeader 每被呼叫一次,就組出一個新物件,內容一樣,身分卻不一樣。而 React 在渲染時讀一次 getSnapshot,之後又會再讀一次去對答案,兩次用 Object.is 比,每次都對不上,於是它判定 store 在這段時間變了,重新渲染;重新渲染又讀到新的物件,如此反覆。
useSyncExternalStore 對 getSnapshot 有一個要求:store 沒變的時候,要交回同一個值。回傳字串的選擇器天生滿足這一點,回傳物件的選擇器則不滿足。
所以,少了比較的代價不是多渲染幾次,而是停不下來。
Header 要的其實是:兩格的內容都沒變,就當作沒變。這需要一種比 Object.is 寬鬆的比較。
逐一比對第一層的每一格,是其中一種定義:
export function shallow<T>(a: T, b: T): boolean {
if (Object.is(a, b)) return true;
if (
typeof a !== "object" ||
a === null ||
typeof b !== "object" ||
b === null
) {
return false;
}
const keysA = Object.keys(a);
if (keysA.length !== Object.keys(b).length) return false;
return keysA.every(
(key) =>
Object.hasOwn(b, key) && Object.is(a[key as keyof T], b[key as keyof T]),
);
}
那麼,這個比較要放在哪裡?
最直接的想法,是替 useSlice 加上第三個引數,讓它記住上一次的切片,拿新的去比。但也可以不動 useSlice,改成把比較包進選擇器本身:
import { useRef } from "react";
export function useShallow<T, U>(selector: Selector<T, U>): Selector<T, U> {
const previous = useRef<U | null>(null);
return (state: T): U => {
const next = selector(state);
if (shallow(previous.current, next)) return previous.current as U;
previous.current = next;
return next;
};
}
它記住上一次交出去的物件。這一次算出來的內容如果淺層相同,就交回上一次那一個。對 useSlice 與 React 來說,身分沒有變,於是視為沒變。
💡
useShallow在渲染階段寫入參考,一次被 React 丟棄的渲染也會改掉previous;shallow也沒有處理Map、Set這類可迭代物件。這裡刻意保持簡單。
呼叫端只要在選擇器外面包一層:
const Header = () => {
const header = useSlice(shopStore, useShallow(selectHeader));
console.log("Header 渲染");
return (
<p>
{header.theme}:{header.cartCount}
</p>
);
};
export const App = () => (
<div>
<Header />
<button
onClick={() => shopStore.setState((s) => ({ ...s, userName: "Leon" }))}
>
改名字
</button>
<button
onClick={() =>
shopStore.setState((s) => ({ ...s, cartCount: s.cartCount + 1 }))
}
>
加一件
</button>
</div>
);
掛載之後只渲染一次。按下「改名字」,主控台沒有新的輸出,因為 Header 沒有取使用者名稱;按下「加一件」,印出一行:
Header 渲染
畫面上變成 light:1。
useSlice 從頭到尾沒有改過,兩次的差別只在呼叫端那一行有沒有包 useShallow。那份「上一次」住在呼叫端元件的 useRef 裡,useSlice 並不知道有比較這件事。
store 只認得同一個參考,React 也只認得同一個參考。至於兩個不同的物件算不算「一樣」,知道的是呼叫端。
不過,淺比較只比一層。如果選擇器把那兩格再包進一個物件:
const selectNestedHeader = (
shop: Shop,
): { header: Pick<Shop, "cartCount" | "theme"> } => ({
header: { cartCount: shop.cartCount, theme: shop.theme },
});
const Header = () => {
const { header } = useSlice(shopStore, useShallow(selectNestedHeader));
console.log("Header 渲染");
return (
<p>
{header.theme}:{header.cartCount}
</p>
);
};
就算同樣包了 useShallow,header 那一格每次都是新物件,第一層的比較就對不上,結果又回到掛載當下的無窮迴圈。
shallow 是「什麼算變了」的一種定義,不是唯一一種。要比到多深、要比哪幾格,取決於呼叫端交回什麼形狀的東西,而那個形狀通常只有呼叫端知道。
Zustand 5 在這裡做了一個選擇。
它的 store Hook 只收一個選擇器,沒有放比較函式的位置。4.x 時代可以在選擇器後面多傳一個 equalityFn,由函式庫記住上一次的切片、用你給的函式去比;那個寫法在 4.x 後期被標示為棄用,到了 5 則移出主要的進入點,另外留在 zustand/traditional 裡。官方文件改推的是 useShallow,也就是把比較包進選擇器。
這看起來像是一次 API 變更,但我覺得它更像是一個關於「什麼算變了由誰決定」的回答。
當函式庫收下一個 equalityFn,它承擔的是保管上一次的值,並在適當的時機去比。當它不收,那一份「上一次」就只能住在呼叫端,比較的形狀也由呼叫端寫出來。函式庫這一側只剩一次 Object.is。
這兩種放法並不是簡單地分成對的和錯的。前者替呼叫端記住了一件事,後者則讓那件事一直攤在呼叫端的那一行上。
而攤開來的代價,剛才已經看過:選擇器寫出了什麼形狀、有沒有包 useShallow,有時候決定的是元件能不能掛得上去,而不只是多渲染幾次。
切片讓 ThemeLabel 不再跟著購物車重新渲染。不過,store 的通知方式其實沒有變。
在 selectTheme 裡加一行:
const selectTheme = (shop: Shop): Shop["theme"] => {
console.log("selectTheme 被呼叫");
return shop.theme;
};
回到那個有 CartBadge、ThemeLabel 與「加一件」的 App,按下「加一件」:
selectTheme 被呼叫
CartBadge 渲染
ThemeLabel 沒有渲染,但它的選擇器被呼叫了一次。
這是因為 createStore 的 setState 仍然會逐一通知每一個聽眾。React 收到通知之後,替每一個訂閱者重新呼叫一次它的 getSnapshot,也就是重新跑一次選擇器,再用 Object.is 判斷那一塊有沒有失效。
切片解決的是「誰需要更新」。至於「誰需要被問一次」,答案仍然是全部訂閱者。
換句話說,切片減少的是重新渲染,不是通知。每一次寫入,store 還是會問遍所有人,只是每個人現在可以用自己的選擇器回答「跟我無關」。
而「跟我有沒有關」的判斷,要靠呼叫端事先把自己在乎的那一塊寫成函式。
使用切片的話你得先知道自己要什麼。那如果狀態本身是一張圖 (graph),由許多彼此有依賴關係的節點組成呢?
本文是以
zustand5.0.15、jotai2.20.3、valtio2.3.2 為基礎,於 2026-08-31 檢閱;實驗跑在react19.3.0 與react-dom19.3.0 環境,於 2026-09-24 進行。