2026/04/06

最近在整理公司的專案時,遇到一個 React 效能開發上的老問題:「當 Store 裡的 Object 或 Array 內容沒變,但因為 Reference(引用)重新產生導致 Component 狂噴 re-render。」
雖然我們可以用 useMemo 或自己寫 useRef 搭配 shallow 來擋,但在 Zustand v4 之後,其實有個更優雅的寫法叫 useShallow。今天就拿我手邊的一個實戰範例,來聊聊為什麼我們該把原本那套「手動穩定化」的邏輯替換掉。
先看這段我之前寫的 useVisibilityManager。這裡的邏輯是從 useChartTabsStore 拿出一整串 tabs,然後過濾出非哨兵(non-sentinel)的項目:
1// 舊的寫法:手動穩定化
2const tabs = useChartTabsStore((s) => s.tabs);
3const selectedTab = useChartTabsStore((s) => s.selectedTab);
4
5const nonSentinelItems = useMemo(() => {
6 const tab = tabs[selectedTab];
7 return tab?.indicator_data.filter((item) => !isChartTypeKey(item.key)) ?? [];
8}, [tabs, selectedTab]);
9
10// 為了怕 non-sentinel 沒變但引用變了,還得搞這招:
11const stableItemsRef = useRef(nonSentinelItems);
12if (!shallow(stableItemsRef.current, nonSentinelItems)) {
13 stableItemsRef.current = nonSentinelItems;
14}
15const stableNonSentinelItems = stableItemsRef.current;這段 code 看起來超累贅對吧? 我明明只是想「如果裡面的內容長得一樣,就不要給我新的 Reference」,結果我卻要用 useRef 去存舊值,還要手動引入 shallow 來比對。這不只讓元件超過 200 行(這是我個人的技術債紅線),而且邏輯變得很碎。
useShallow 會發生什麼事?Zustand 提供的 useShallow Hook 就是為了處理這種「淺層比對」的場景。我們可以直接在 Selector 層級就完成這件事。
如果我們用 useShallow 重構,程式碼會變得乾淨很多:
1import { useShallow } from 'zustand/react/shallow';
2
3// ... 其他 code
4
5export function useVisibilityManager({ stockList, indicatorList }: UseVisibilityManagerParams) {
6 // 直接在 Selector 就處理掉引用穩定化的問題
7 const nonSentinelItems = useChartTabsStore(
8 useShallow((s) => {
9 const tab = s.tabs[s.selectedTab];
10 return tab?.indicator_data.filter((item) => !isChartTypeKey(item.key)) ?? [];
11 })
12 );
13
14 // 接下來就可以直接用 nonSentinelItems,不用在那邊搞 Ref 了!
15 const visibleData = useMemo(() => {
16 // ... 運算邏輯
17 }, [indicatorList, stockList, nonSentinelItems]);
18
19 // ...
20}useRef 和手動判斷 shallow 的冗長邏輯,維持元件的簡潔。tabs 裡面的其他無關資料變動時,只要 filter 出來的結果內容是一樣的,nonSentinelItems 的引用就不會變,進而避免了下游 visibleData 這個 useMemo 被無謂地觸發。身為前端工程師,追求的不只是功能動得起來,更要追求程式碼的品質與開發體驗。
以前我們可能得自己刻很多工具函式來擋 re-render,但現在有 useShallow 這種官方提供的語法糖,真的沒理由再去寫那種滿滿 useRef 的補丁。如果你也在用 Zustand,強烈建議把這種穩定化邏輯都收進 useShallow 裡。