「我的 ProfilePage、SettingsPage、DashboardPage 都有一段讀寫 localStorage 的邏輯, 複製貼上了三次,改一個地方就要改三個地方……」
這是幾乎每個初學 React 的人都會遇到的痛點。解法不是「更仔細地複製貼上」, 而是學會把重複邏輯抽成 Custom Hook。這篇會帶你從痛點出發, 一步步建立三個實用的 Custom Hook,並在最後展示如何組合它們。
痛點:三個元件寫了三次一樣的邏輯
想像這個場景:你的 app 有三個頁面,都需要把使用者偏好存到 localStorage, 並在元件掛載時讀回來。你可能會在每個元件裡寫出這樣的程式碼——
這段重複代碼有什麼問題?
- • 邏輯改了(例如加上 JSON.parse 容錯),要在三個地方分別修改
- • localStorage 的 key 名稱打錯,三個地方都要找
- • 寫測試時,同樣的邏輯要測三次
- • 新同事加入,看到三份一樣的東西,不知道哪個才是「正確版本」
解法是:把這 15 行重複邏輯「搬出來」,放到一個以 use 開頭的函式裡。 這就是 Custom Hook 的核心概念——把邏輯抽離元件,讓元件只專注在「畫什麼」。
Custom Hook 的三條規則
Custom Hook 不是什麼魔法,它就是一個普通的 JavaScript/TypeScript 函式, 只是必須遵守三條規則。違反這三條規則,React 不知道怎麼正確追蹤你的狀態。
名稱必須以 use 開頭
React 的 linter(eslint-plugin-react-hooks)靠這個命名規則辨識 Hook。 如果你命名為 localStorageHook,它就不是 Hook, 裡面呼叫 useState 會報錯或行為異常。
✓ useLocalStorage ✓ useDebounce ✓ useFetch
可以在裡面呼叫其他 Hook
Custom Hook 裡面可以呼叫 useState、useEffect、useRef, 甚至呼叫其他 Custom Hook。 這讓你可以把複雜邏輯「一層一層組合」,就像積木一樣。
可以回傳任何東西
不像元件必須回傳 JSX,Hook 可以回傳數值、函式、物件、陣列, 或完全不回傳。回傳什麼,取決於呼叫端需要什麼。
生活類比:Custom Hook 就像一個「私人助理」。 你告訴助理「幫我追蹤 localStorage 裡的 theme 值」, 助理會自動幫你讀取、儲存、同步,並隨時回報最新值給你。 你(元件)不需要知道助理怎麼做到的,你只要用結果就好。
useLocalStorage — 第一個實用 Hook
我們來解決開頭那個痛點。把讀寫 localStorage 的邏輯抽出來, 做成一個可以重用於任何型別的泛型 Hook:
Lazy Initializer 是什麼?
useState(fn) 傳函式而非直接傳值, React 只在第一次 render 時執行這個函式。 直接寫 localStorage.getItem(key)的話,每次 render 都會執行一次,效能較差。
TypeScript 泛型 <T>
泛型讓這個 Hook 可以用於任何型別:string、number、物件都行。 TypeScript 會自動推斷 value 的型別, 你不需要每次都手動指定。
as const 的作用
as const 讓 TypeScript 把回傳值視為 tuple(固定長度陣列)而非 array, 解構時 value 是 T、setter 是 function,型別正確。
有了這個 Hook,原本三個元件各自寫的 15 行,現在變成 1 行:
三個頁面改起來了嗎?只要把各自的 useState + useEffect 換成 useLocalStorage, 就完成重構。以後 localStorage 邏輯要改,只改一個地方。
useDebounce — 搜尋框的效能救星
假設你做了一個搜尋框,每當使用者輸入,就打一次 API 搜尋結果。 聽起來很合理,但實際上——
沒有 debounce 的搜尋框(效能殺手)
使用者輸入 10 個字的過程中,你發了 10 次請求。前 9 次的結果根本沒用, 卻浪費了伺服器資源和使用者的流量。
Debounce 的概念是:「等使用者停止輸入 X 毫秒後,才執行動作。」 把這個等待邏輯抽成 Hook:
用起來非常直觀——加一行 useDebounce, 搜尋框就從「每字都 fetch」變成「停止輸入後才 fetch」:
為什麼 Cleanup 很重要?
return () => clearTimeout(timer) 這行是關鍵。
想像使用者連續輸入三個字:a、ab、abc。 每次輸入都會觸發 useEffect,建立一個新的 timer。 如果不清掉舊的 timer,三個 timer 會「競速」, 可能造成 race condition——最後回來的結果不一定是最新的那個。 Cleanup 確保永遠只有「最後設定的那個 timer」有效。
useFetch — 把資料請求邏輯封裝起來
每個需要顯示 API 資料的元件,都需要 loading 狀態、error 狀態、data 狀態…… 每次都重複寫一樣的模板代碼。把它封裝成 useFetch:
AbortController 防止 Memory Leak
如果使用者快速切換頁面,元件在 API 還沒回來就卸載了。 此時如果 fetch 完成並嘗試呼叫 setState,React 會警告你「在已卸載的元件上設定狀態」, 這就是 memory leak 的前兆。controller.abort() 會在元件卸載時取消請求,err.name !== 'AbortError' 確保主動取消不被誤判為錯誤。
有了 useFetch,任何資料顯示頁面都可以用三行解決所有狀態管理:
組合 Hooks — usePagination 綜合練習
Custom Hook 真正強大的地方,在於可以把多個 Hook 組合成更複雜的邏輯。 分頁(pagination)就是一個好例子——它需要資料、目前頁數、計算邊界, 但這些邏輯全部可以封裝在一個 Hook 裡:
更進一步,我們可以把 useFetch 和 usePagination 結合起來, 完成一個具備「資料請求 + 分頁」功能的複合 Hook:
PostListPage 本身只有 30 行,但功能完整。邏輯全部在 Hook 裡, 元件只負責「怎麼畫」,這才是 Custom Hook 的最終目標。
什麼情境用什麼 Hook?
| 使用場景 | 推薦 Hook |
|---|---|
| 表單欄位持久化、用戶偏好設定 | useLocalStorage |
| 搜尋框、Autocomplete 輸入 | useDebounce |
| 任何 API 資料顯示頁面 | useFetch |
| 列表、表格的分頁控制 | usePagination |
| RWD 版型切換、視窗尺寸偵測 | useWindowSize |
本篇重點整理
- Custom Hook = 以 use 開頭、可呼叫其他 Hook 的普通函式
- useLocalStorage:持久化任意型別,lazy initializer 避免不必要執行
- useDebounce:Cleanup 防止 race condition,只有最後一次輸入才觸發
- useFetch:AbortController 防止元件卸載後的 memory leak
- Hook 可以互相組合,從簡單邏輯構建複雜功能