iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
Modern Web

再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學系列 第 10 篇

【 Day 09 】 你怎麼告訴 React,你在乎哪一塊?|共享狀態(二)

  • 分享至 

  • xImage
  •  

昨天我們用 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),由許多彼此有依賴關係的節點組成呢?

本文是以 zustand 5.0.15、jotai 2.20.3、valtio 2.3.2 為基礎,於 2026-08-31 檢閱;實驗跑在 react 19.3.0 與 react-dom 19.3.0 環境,於 2026-09-24 進行。


上一篇
【 Day 08 】訂閱一個活在 React 之外的狀態,至少要做到什麼?|共享狀態(一)
下一篇
【 Day 10 】如果訂閱的單位不是切片,而是圖上的節點?|共享狀態(三)
系列文
再造輪子:30 天臨摹 React Hook 函式庫,探索背後的設計哲學 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言