Hooks 是為了解決什麼問題而誕生的?(不是「比 class 潮」)
為什麼 hooks 只能在元件頂層呼叫、不能放進 if 裡?
的正確心智模型是什麼?依賴陣列到底是什麼意思?
什麼時候該把邏輯抽成自訂 hook? 和 的定位?
你每天都在用 hooks,但「為什麼 React 要發明 hooks」這個問題的答案, 決定了你會把它們用在對的地方,還是只是照抄範例。 這一章先回到 hooks 誕生前的世界,看清楚它要解決的問題, 再逐一拆解每個 hook 的設計理由。
Hooks 之前的世界:class 元件的三個痛點
2019 年之前,有狀態的元件只能寫成 class。它有三個越用越痛的問題:
痛點一:有狀態的邏輯難以復用。 「追蹤視窗大小」「訂閱聊天室」「表單欄位管理」這類邏輯都帶著自己的狀態與生命週期, 偏偏 class 元件的狀態與生命週期都焊死在元件本體上,抽不出來。 社群發明了兩種 workaround,各有致命傷: 高階元件(HOC,如 ) 讓元件樹多出一堆包裝層,props 從哪個包裝層來的無從查起,還會撞名; render props() 一旦要同時復用三種邏輯,JSX 就變成三層巢狀的金字塔。 兩者的共同缺陷:為了復用「邏輯」,卻被迫改變「元件樹的結構」。
痛點二:生命週期方法把「一件事」切成三段,又把「三件事」混在一段。
class ChatRoom extends React.Component<Props> {
componentDidMount() {
this.subscribe(this.props.roomId); // 訂閱:第 1 段
trackPageView(); // 順便混進了無關的埋點
}
componentDidUpdate(prev: Props) {
if (prev.roomId !== this.props.roomId) { // 訂閱:第 2 段
this.unsubscribe(prev.roomId); // (這段最常被忘記寫)
this.subscribe(this.props.roomId);
}
}
componentWillUnmount() {
this.unsubscribe(this.props.roomId); // 訂閱:第 3 段
}
}
「維持聊天室訂閱」是一個完整的關注點,卻被按時間點切散在三個方法裡; 而 裡又常常同時塞著訂閱、埋點、抓資料等互不相干的事。 想讀懂「訂閱是怎麼運作的」,你得在三個方法之間跳來跳去,還要自行過濾雜訊。
痛點三: 與樣板碼。忘記 、 建構子裡一排 、 的淺合併規則……這些都是與業務無關的認知負擔。
Hooks 讓「有狀態的邏輯」變成可以用函式呼叫來組合的單位:
程式碼按關注點組織(一個 管一件事、一個自訂 hook 封裝一個能力), 而不是按生命週期的時間點切割。
復用邏輯=抽一個函式(自訂 hook),完全不改變元件樹—— 對比 HOC 與 render props 的最大差異。
上面的 ,用 hooks 寫就是一個 (訂閱+清理+換房間,三段合一) 加一個獨立的 (埋點),每個關注點自成一格。
為什麼 hooks 有那些「奇怪的規則」
「只能在最頂層呼叫,不能放進條件、迴圈」——這條規則不是風格偏好, 而是 hooks 儲存機制的直接結果,值得花三分鐘徹底弄懂。
是個普通函式,它怎麼知道「這次呼叫」對應哪一份狀態? 答案:React 在每個元件實例(fiber 節點)上維護一個hook 狀態串列, 渲染時按呼叫順序一格一格對應——第 1 次 hook 呼叫拿第 1 格、第 2 次拿第 2 格, 不看變數名,只看順序。
| 儲存格 | 第一次渲染 | 第二次渲染(若 if 成立時跳過一個 hook) |
|---|---|---|
| 0 | → 存名字 | → 讀到名字(\(\checkmark\) 正確) |
| 1 | → 存計數 | (被 if 跳過) |
| 2 | → 讀到「計數」的格子,全面錯位 |
只要某次渲染少呼叫(或多呼叫)了一個 hook, 後面所有 hook 都會對到錯的格子——狀態張冠李戴,直接崩潰。 所以規則的真正內容是:每次渲染的 hook 呼叫順序必須完全一致, 「不放進 if/迴圈/early return 之後」只是保證這件事的手段。 需要條件行為時,條件寫在 hook 裡面(例如 effect 內先 ), 而不是有條件地呼叫 hook。 就是在自動檢查這件事,公司專案幾乎一定裝了—— 它報的錯不要用註解壓掉,那通常代表你正要製造一個錯位或過期閉包的 bug。
理論上可以要求 用字串當 key,就不受順序限制。 但名字會在複製貼上時撞名、在兩個自訂 hook 內部互相衝突—— 「順序即身分」反而讓每個自訂 hook 天然擁有完全隔離的狀態空間, 這是「自訂 hook 可以任意組合而互不干擾」的基礎。 規則是這個設計換來的代價,而且能被 lint 工具完全自動檢查。
useState 進階:狀態存在哪裡、閉包會過期
第 3 章建立了「渲染即快照」;配上剛才的儲存機制,可以把模型補完整: 狀態存在 React 的 fiber 上,元件函式只是每次渲染被重新執行的「配方」。 函式裡的變數、閉包每次渲染都是新的,但 從 fiber 讀回來的值是延續的。
這個模型直接解釋了最常見的 hooks bug——過期閉包(stale closure):
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // 這個閉包捕捉的是「掛載那次渲染」的 count = 0
}, 1000);
return () => clearInterval(id);
}, []); // 空依賴:effect 只在掛載時建立一次
return <p>{count}</p>; // 永遠停在 1
}
interval 的回呼是掛載那次渲染建立的閉包,它捕捉的 永遠是 , 所以每秒都在執行 。畫面卡在 1,不多不少。 三種修法,各對應一種思路:
// 修法一(最好):函數式更新——根本不需要讀 count
setCount((c) => c + 1);
// 修法二:誠實申報依賴——count 變了就重建 interval(每秒拆裝一次,可行但較粗)
useEffect(() => { ... }, [count]);
// 修法三:用 ref 保存「最新值」(進階,見 4.5 節)
看到 時第一個反射:新值需要用到舊值嗎? 需要就寫函數式更新 。 這個習慣同時消滅「快照疊加」(第 3 章)和「過期閉包」兩類 bug, 而且讓函式可以安全地從依賴陣列中省略對狀態值的依賴。
useEffect:是「同步」,不是「生命週期」
是被誤解最深的 hook,因為多數人帶著 class 的舊地圖來認路: 「空陣列=,有依賴=」。 這個對照表能讓你寫出會動的程式碼,但它是錯的地圖,遲早把你帶進溝裡。
正確的模型:useEffect 描述「如何讓某個外部系統與 React 的狀態保持同步」。 外部系統指 React 管不到的東西:網路連線、訂閱、計時器、DOM API、第三方函式庫。
effect 本體:如何開始同步(建立連線、掛上監聽器)。
cleanup:如何停止同步(斷線、移除監聽器)。
依賴陣列:這段同步邏輯讀取了哪些會變的值—— 不是「什麼時候執行」的開關,而是「同步的內容取決於誰」的誠實申報。 依賴變了 = 舊的同步已經過時 = React 先跑 cleanup 再跑新的 effect。
用這個模型看聊天室,三段生命週期合成一段自然的敘述:
useEffect(() => {
const conn = connect(roomId); // 開始同步:連上這個房間
return () => conn.close(); // 停止同步:離開這個房間
}, [roomId]); // 同步的對象取決於 roomId
從 A 變成 B 時,React 自動執行「 → 」。 class 版本裡最容易忘記寫的 分支,在這個模型下根本不存在—— 你沒有機會忘記。這就是「按關注點組織」的實際威力。
把依賴陣列當「時機開關」的人,會為了「我只想跑一次」而故意留空陣列、 或為了「我不想它重跑」而從陣列裡偷偷拿掉某個值。 後者製造的就是上一節的過期閉包:effect 讀著舊渲染的值繼續工作。 規矩只有一條:effect 裡讀到的所有 reactive 值(props、state、渲染中算出的變數)都必須列進依賴。 如果「誠實申報之後 effect 跑太頻繁」,問題永遠出在別處—— 值的引用不穩定(用 / 穩定它,第 3 章)、 不需要 reactive 的值混了進來(搬出去或用 ref)、 或這段邏輯根本不該是 effect(見下方清單)。
抓資料的競態問題也是同步模型的直接應用。 依賴變了會先跑 cleanup——這正是取消過期請求的正確位置:
useEffect(() => {
let ignore = false; // 本次同步的「已過時」旗標
fetchUser(userId).then((user) => {
if (!ignore) setUser(user); // 慢回來的舊請求會被這裡擋掉
});
return () => { ignore = true; };
}, [userId]);
沒有這層防護,使用者快速從使用者 A 切到 B 時, 若 A 的回應比 B 晚到,畫面就會顯示 B 的網址配 A 的資料。 (用 是更完整的版本:cleanup 時直接 取消請求。) 這也是 React 18 StrictMode 在開發模式故意「掛載→卸載→再掛載」跑兩次 effect 的原因: 逼你把 cleanup 寫對——寫對的 effect 跑兩次結果不變,寫錯的當場現形。 所以「開發環境 API 都打兩次」不是 bug,是體檢。
最後是同樣重要的反面清單。以下情況不該用 effect:
從現有狀態算另一個值——那是衍生資料,渲染時直接算(第 2 章)。
回應使用者操作——「按下按鈕要送出訂單」的邏輯屬於事件處理器; 塞進 effect(「監聽某狀態變 true 就送出」)會讓因果鏈斷裂: 讀的人無法從事件追到後果,而且狀態被其他路徑改到時會誤觸發。
「狀態 A 變了就去 set 狀態 B」的連鎖——通常是狀態設計錯誤的訊號, 回到第 2 章的最小狀態原則重新想。
判斷準則一句話:effect 是給「與外部系統同步」用的逃生口; 不涉及外部系統的邏輯,幾乎都有更好的位置。
useRef:不參與渲染的可變盒子
回傳一個在所有渲染之間共享的盒子 : 改它不觸發渲染,讀它永遠拿到最新值——正好是 state 的鏡像互補。
| useState | useRef | |
|---|---|---|
| 改變時重新渲染? | 會 | 不會 |
| 渲染中讀到的值 | 該次渲染的快照 | 永遠是最新值(同一個盒子) |
| 適合存放 | 會反映在畫面上的資料 | 與畫面無關的機制性資料 |
三種典型用途:
// 1. 操作 DOM:宣告式模型的正式逃生口(聚焦、捲動、量尺寸)
const inputRef = useRef<HTMLInputElement>(null);
<input ref={inputRef} />; // 之後可呼叫 inputRef.current?.focus()
// 2. 保存機制性資料:計時器 id、WebSocket 實例、上一次的值
const timerRef = useRef<number | null>(null);
// 3. 讓長壽命閉包讀到最新值(解過期閉包的第三條路)
const latestQuery = useRef(query);
useEffect(() => { latestQuery.current = query; }); // 每次渲染後更新
判斷「該 state 還是該 ref」只需一個問題:這個值改變時,畫面需要跟著變嗎? 需要→state;不需要(純機制)→ref。 反過來的錯誤也常見:把該進 ref 的計時器 id 放進 state, 結果每次設定計時器都觸發一次毫無意義的重新渲染。
自訂 Hook:邏輯復用的正確單位
把前面所有觀念收攏起來的,就是自訂 hook—— 一個以 開頭、內部可以呼叫其他 hooks 的普通函式。 它是 hooks 系統回應「痛點一」的正式答案:復用有狀態的邏輯,不動元件樹。
看一個工作中幾乎必然遇到的例子——搜尋防抖:
// useDebounce.ts:把「值的防抖」封裝成可復用的能力
export function useDebounce<T>(value: T, delayMs = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(id); // 值又變了:取消上一輪倒數
}, [value, delayMs]);
return debounced;
}
// 使用端:讀起來像宣告一個事實,細節完全隱形
function SearchPage() {
const [keyword, setKeyword] = useState('');
const debouncedKeyword = useDebounce(keyword, 300);
const results = useSearchResults(debouncedKeyword);
// ...
}
幾個值得內化的觀察:
封裝的是「意圖」:使用端只看到「防抖後的關鍵字」這個概念, 與 cleanup 的舞步被藏起來。好的自訂 hook 讓元件讀起來像需求描述。
每次呼叫都是獨立實例:兩個元件各自呼叫 , 狀態完全隔離(回想「順序即身分」的儲存機制)——這是 mixin 與 HOC 做不到的乾淨。
泛型 讓它對任何型別可用且不丟失型別資訊—— 傳入 就回傳 。這是第 6 章泛型的預告。
名字必須 開頭不是裝飾:它讓 lint 工具知道 「這個函式內部有 hooks」,從而檢查呼叫順序規則。
兩個訊號:(1) 兩個元件出現重複的「state + effect」組合; (2) 單一元件裡,某群 state 與 effect 明顯屬於同一個關注點、 與其他邏輯糾纏在一起(就算不復用,抽出來也讓兩邊都更好讀、更好測)。 公司程式碼裡的 、、 都是這樣長出來的——先有具體重複,再有抽象,順序不要反過來。
useReducer:把「狀態怎麼變」集中成一張規則表
多個 總是一起變、更新邏輯散落在各個事件處理器、 新同事不知道「還有哪些地方會改這包狀態」——這是 出場的訊號:
type CartAction =
| { type: 'add'; item: Item }
| { type: 'remove'; itemId: string }
| { type: 'clear' };
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'add': return { ...state, items: [...state.items, action.item] };
case 'remove': return { ...state, items: state.items.filter((i) => i.id !== action.itemId) };
case 'clear': return { ...state, items: [] };
}
}
const [cart, dispatch] = useReducer(cartReducer, initialCart);
// 事件處理器只剩「宣告發生了什麼」:dispatch({ type: 'add', item })
它買到的東西:所有合法的狀態轉移集中在一個純函式裡(一眼讀完、可以單獨測試、 新增轉移時 TypeScript 的窮盡檢查會提醒你漏了哪個 case,見第 6 章); 事件處理器退化成「報告事件」; 而且 的引用天生穩定,往下傳不需要 。 你會發現這正是 Redux 的核心思想縮小到單一元件——讀懂這個,公司若在用 Redux/RTK,模型是同一套。
Hooks 解決 class 的核心痛:邏輯按關注點組織、復用邏輯不改變元件樹。
「只能頂層呼叫」源自「順序即身分」的儲存機制;條件寫在 hook 內部,不是有條件地呼叫 hook。
狀態存在 fiber 上,函式每次渲染重跑;閉包捕捉快照 → 過期閉包;函數式更新是第一線解法。
= 與外部系統同步:本體開始、cleanup 停止、依賴陣列是「同步讀了誰」的誠實申報;衍生資料與事件邏輯不屬於 effect。
是不觸發渲染的可變盒子:DOM、機制性資料、最新值。
自訂 hook 封裝「state+effect 的關注點」; 把狀態轉移集中成可測試的規則表。