4.2 Hooks 與狀態管理:從代數效應到一致性感#
Hooks 解決了什麼問題#
Class 元件時代(2013–2019)的三大痛:
- 邏輯複用困難:mixin 與 HOC 的巢狀地獄(wrapper hell)
- 生命週期碎片化:一個訂閱邏輯散落在
componentDidMount/componentDidUpdate/componentWillUnmount三處 - this 陷阱:綁定錯誤、類別心智負擔
Hooks(2019,React 16.8)的答案是把狀態與副作用變成可組合的函數呼叫。理論上它與 代數效應(algebraic effects) 相關:useState 像是一個「向框架請求狀態」的效應操作,框架(效應處理器)決定如何回應 —— 這讓元件程式碼不需知道狀態儲存在哪。
核心 Hooks 的語意#
const [count, setCount] = useState(0); // 狀態
useEffect(() => {
const sub = subscribe(channel);
return () => sub.unsubscribe(); // 清理函數
}, [channel]); // 依賴陣列
const memo = useMemo(() => expensive(a, b), [a, b]); // 計算快取
const cb = useCallback(() => send(memo), [memo]); // 引用穩定
const ref = useRef<HTMLDivElement>(null); // 可變引用(不觸發渲染)
useEffect 的本質:與外部系統同步#
官方文件(2023 重寫)的精確定位:Effect 是讓元件與外部系統保持同步的逃生艙,不是生命週期掛鉤。正確使用的判準:
| 情境 | 用 Effect? |
|---|---|
| 資料抓取(需快取/重試) | ❌ 用 react-query |
| 由 props/state 推導的值 | ❌ 直接在渲染中計算 |
| 訂閱 WebSocket、瀏覽器事件 | ✅(訂閱的是「React 之外」的系統) |
| 動畫、焦點管理 | ✅ |
「你或許不需要 Effect」:大多數 Effect 是把本可宣告式計算的東西寫成了命令式同步 —— 這是從宣告式典範的倒退。
依賴陣列的正確理論#
依賴陣列不是「何時執行的提示」,而是正確性約束:
違反它 = 閉包捕獲了過期值(stale closure)。React 提供的 lint 規則(exhaustive-deps)把這條數學條件變成編譯期附近的靜態檢查 —— 又是「把錯誤往左移」。
自訂 Hook:行為的複用單位#
function useDebounced<T>(value: T, ms: number): T {
const [v, setV] = useState(value);
useEffect(() => {
const t = setTimeout(() => setV(value), ms);
return () => clearTimeout(t);
}, [value, ms]);
return v;
}
自訂 Hook 是「呼叫其他 Hook 的函數」—— 行為像 mixin,但沒有命名衝突與隱式依賴,因為資料流是顯式的參數與回傳值。
伺服器狀態:react-query 的理論#
客戶端狀態(UI 開關)與伺服器狀態(資料庫的副本)是本質不同的問題。後者有:快取失效、競態條件、分頁、樂觀更新。手寫 useEffect + useState 管理,等於重新發明一個殘缺的快取系統。
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
function useTodos() {
return useQuery({
queryKey: ['todos'],
queryFn: api.listTodos, // 型別自動推導
});
}
function useToggleTodo() {
const qc = useQueryClient();
return useMutation({
mutationFn: api.toggleTodo,
onSuccess: () => qc.invalidateQueries({ queryKey: ['todos'] }),
});
}
競態條件的消滅#
使用者快速切換分頁時,兩個請求的回應可能亂序到達。手寫方案需 AbortController + 請求序號;react-query 內建:queryKey 變更即取消舊查詢,且以 key 為單位快取 —— 競態條件被框架結構性消滅。
flowchart LR
A[mutation 成功] --> B[invalidate key]
B --> C[標記快取過期]
C --> D[自動 refetch]
D --> E[UI 一致性恢復]全域狀態:Context 與其代價#
const AuthContext = createContext<Auth | null>(null);
function App() {
const [auth] = useState(loadAuth);
return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>;
}
Context 的理論限制:value 變更會重渲染所有消費者。因此它適合低頻變更(登入狀態、主題),不適合高頻資料 —— 那是 react-query 或 Zustand 的領域。
習題#
- 把「點擊外部關閉下拉選單」寫成自訂 Hook,標出 Effect 的清理邏輯。
- 說明 react-query 如何同時解決快取與競態兩個問題。
- 以下程式碼的 stale closure 問題在哪?如何修?
const [count, setCount] = useState(0); useEffect(() => { const t = setInterval(() => setCount(count + 1), 1000); return () => clearInterval(t); }, []);