檔案列表要支援批次多比操作:每一列前面一個勾選框,勾了之後,頁面最上方的工具列要顯示「已選 3 個」,右側面板要列出選中的檔案。
三個元件,其實散在頁面三個角落,共用同一份「選了哪些」。
在 Angular,這是我常常寫東西。到了 React,我先用 Context 做了一版,能動,功能完全正確。
然後我打開 React DevTools,勾上「Highlight updates when components render」,點了一個勾選框。
整頁都閃了一下。
工具列閃、右側面板閃、五百列檔案全部閃。我只勾了一個方框,五百列都重新 render 了一遍。列表開始有點卡。
在 Angular,跨元件共享的狀態,我的標準做法是這樣:
@Injectable({ providedIn: 'root' })
export class SelectionService {
private selected$ = new BehaviorSubject<string[]>([]);
readonly selected = this.selected$.asObservable();
toggle(id: string) {
const current = this.selected$.value;
this.selected$.next(
current.includes(id) ? current.filter(x => x !== id) : [...current, id]
);
}
}
工具列、檔案列、右側面板,各自 inject(SelectionService),各自訂閱自己需要的部分:
// 工具列只要數量
count$ = this.selection.selected.pipe(map(s => s.length));
這個模式有三個特點,我以前從來沒意識到它們有多重要:
map 取出需要的片段,再加個 distinctUntilChanged,值沒變就不通知。Signals 時代換成 signal(),但結構一樣:一份狀態住在 service 裡,誰讀了誰才會被更新。
React 沒有 service,Day 6 說過,跨元件共享要嘛狀態提升,要嘛用 Context。三個元件離太遠,我選了 Context:
const SelectionContext = createContext<SelectionValue | null>(null);
export function SelectionProvider({ children }: { children: ReactNode }) {
const [selected, setSelected] = useState<string[]>([]);
const value = useMemo(() => ({
selected,
toggle: (id: string) => setSelected(prev =>
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
),
}), [selected]);
return <SelectionContext.Provider value={value}>{children}</SelectionContext.Provider>;
}
useMemo 也包了(Day 4 教的),看起來很完整。
問題出在 Context 的運作方式:value 一換新的參考,所有讀這個 Context 的元件都會重新 render。
而每一列 FileRow 都需要讀它——要知道自己有沒有被選取,也要拿 toggle 來用。所以勾一個方框:
selected 變了 → value 換成新物件 → 所有 useContext(SelectionContext) 的元件重新 render
→ 工具列、面板,還有五百個 FileRow
真正需要更新的只有兩列(剛勾的那列,跟數量變了的工具列),但 Context 沒辦法只通知它們。它不知道誰在意哪一部分,只能全部叫醒。
這時候我才看懂:Context 是為了解決 prop drilling 而存在的。 它是一條運輸管道,把值從上面送到下面。它不是一個倉庫,沒有「只訂閱一部分」這種能力。
Angular 的 BehaviorSubject 兩者都是。React 的 Context 只是前者。
抗拒期嘛。我要的東西很清楚:一份住在元件之外的狀態,加上一群可以各自訂閱的元件。這不就是 BehaviorSubject?
function createStore<T>(initial: T) {
let state = initial;
const listeners = new Set<() => void>();
return {
get: () => state,
set: (next: T) => {
state = next;
listeners.forEach(listener => listener());
},
subscribe: (listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
一個值、一群監聽者、一個通知的方法。十幾行,就是一個陽春版的 BehaviorSubject。
接到 React 上,要用一個 React 18 之後提供的 hook:
export const selectionStore = createStore<string[]>([]);
function useSelection<S>(selector: (state: string[]) => S): S {
return useSyncExternalStore(
selectionStore.subscribe,
() => selector(selectionStore.get()),
);
}
useSyncExternalStore 就是 React 官方給「外部倉庫」接進來的插座:你告訴它怎麼訂閱、怎麼取值,它負責在值變了的時候讓元件更新。
關鍵在 selector。每個元件只取自己要的那一塊:
// FileRow:只關心自己有沒有被選
const isSelected = useSelection(s => s.includes(file.id));
// 工具列:只關心數量
const count = useSelection(s => s.length);
勾一個方框,所有訂閱者都會被通知,但 React 會拿 selector 的回傳值跟上一次比較——又是 Object.is。第 1 列到第 499 列的 isSelected 沒變(還是 false),就不重新 render。
打開 DevTools 再點一次:只有剛勾的那一列、還有工具列閃了一下。
map 加 distinctUntilChanged,在這裡變成了 selector 加 Object.is。
寫完我很得意,拿去給同事看。他看了一眼說:「這不就是 Zustand?」
我去翻了 Zustand 的原始碼。核心的部分,跟我剛剛寫的東西幾乎一模一樣:一個閉包裡的 state、一個 Set 裝監聽者、再用 useSyncExternalStore 接到 React。整個套件壓縮後大約 1KB。
用 Zustand 改寫:
import { create } from 'zustand';
interface SelectionState {
selected: string[];
toggle: (id: string) => void;
clear: () => void;
}
export const useSelection = create<SelectionState>()(set => ({
selected: [],
toggle: id => set(s => ({
selected: s.selected.includes(id)
? s.selected.filter(x => x !== id)
: [...s.selected, id],
})),
clear: () => set({ selected: [] }),
}));
用起來:
const isSelected = useSelection(s => s.selected.includes(file.id));
const toggle = useSelection(s => s.toggle);
const count = useSelection(s => s.selected.length);
沒有 Provider。沒有 Context。store 定義在元件之外,任何元件 import 進來就能用,而且只會因為自己取的那一塊變了才重新 render。
看著這段程式碼,我有一種很久沒有的熟悉感:這根本就是一個 providedIn: 'root' 的 service。
| Angular | Zustand |
|---|---|
@Injectable({ providedIn: 'root' }) |
在模組層級 create() 一次 |
BehaviorSubject 裡的值 |
store 裡的 state |
service 的方法裡呼叫 .next() |
store 的 action 裡呼叫 set() |
inject(SelectionService) |
useSelection() |
map(s => s.length) |
selector s => s.selected.length |
distinctUntilChanged() |
selector 回傳值的 Object.is 比較 |
甚至連「在元件之外用」都一樣。Angular 可以在另一個 service 裡注入它,Zustand 可以直接呼叫:
useSelection.getState().clear(); // 在事件處理、工具函式裡都能用
Object.is 的坑selector 如果回傳一個新的物件或陣列,每次都會被判定成「變了」:
// ❌ 每次都 return 一個新物件
const { count, selected } = useSelection(s => ({
count: s.selected.length,
selected: s.selected,
}));
輕則每次都重新 render,重則在新版 Zustand 裡直接造成無限更新。要一次取多個值,就分開呼叫,或用 Zustand 提供的 useShallow 改成淺比較:
import { useShallow } from 'zustand/react/shallow';
const { count, selected } = useSelection(
useShallow(s => ({ count: s.selected.length, selected: s.selected }))
);
Object.is 已經是這個系列出場次數最多的角色了。
它是模組單例,不是 DI。 Day 2 講過,export const 出來的東西沒辦法像 providers 那樣在測試裡換掉。Zustand 的做法是在每個測試前把狀態重設:useSelection.setState({ selected: [] })。能用,但心態上要調整。
它在伺服器上是共用的。 也是 Day 2 埋的伏筆:模組單例在 SSR 時,是整個 Node process 共用一份。所以「選了哪些檔案」這種只在瀏覽器裡發生的 UI 狀態放進去沒問題,但如果在 loader 或伺服器端寫入跟使用者有關的資料,就會在不同使用者之間互相汙染。這個 Week 4 再細講。
BehaviorSubject 放在 service 裡,本質上是兩件事:一份住在元件之外的值,加上一群可以只訂閱一部分的監聽者。
React 的 state 住在元件裡面。要讓散在各處的元件共享它,Context 能把值送過去,卻沒辦法讓訂閱者各取所需——它是運輸管道,不是倉庫。
所以 React 生態需要「外部 store」這一層。而 Zustand 做的事情,拆開來看就是我在 Angular 裡天天在用的東西:一個全域的 service,裡面放一個 BehaviorSubject,外面的人用 selector 挑自己要的那一塊。
但有件事要先講清楚:Zustand 管的是 UI 狀態——選了什麼、篩選條件、側欄開沒開。它不是拿來放「從 API 抓回來的資料」的。
那一塊,才是我這一週所有手刻 hook 真正在處理的東西:競態、快取、重試、debounce 之後的請求。