FE Day 10 自訂 Hook:把邏輯變成可重用的積木
執行需求:CPU 可跑。今天是系列的第十篇,正式進入「核心」章節。昨天的 useEffect 範例裡,我們把「自動儲存到 localStorage」的邏輯寫成 usePersistentDraft 函式;那支函式呼叫了 useState 與 useEffect,本身已經是 Hook 的雛形。今天要把這件事攤開來講:自訂 Hook(custom hook)是 React 重用「帶狀態邏輯」的標準機制,比元件拆解更高階、比工具函式更有彈性。我們會把昨天的範例升級成可重用版本,再用三個小型 Hook(useToggle、useDebounce、useFetch)演示「組合 Hook」的威力。整篇範例都在本機 CPU 跑得起來,不依賴雲端服務。
引言
寫後端的時候我們對「邏輯重用」很熟悉:把常用的操作抽成 service 函式、把工具方法放進 utils 模組、把一組相關的常數放進 constants 檔。前端這邊有一個對應機制,但層級不同:React 18 之後興起的「Hook」不只是「重用程式碼」,更是「重用『帶狀態』的程式碼」。一段邏輯如果有用到 useState 或 useEffect,就適合做成 Hook;如果只是純函式,放進 utils 模組即可。
自訂 Hook 不是 React 的新 API,而是「用 useState 等內建 Hook 組合出來的函式」。這個函式遵守兩個規則才能被 React 識別為 Hook:第一,名稱必須以 use 開頭(useToggle、useFetch、useDebounce);第二,在它裡面呼叫其他 Hook 時,每次 render 都要用同樣的順序、同樣的數量。這兩個規則讓 React 的 Hook 檢查機制(linter)能正確運作。
今天要解決五個問題:第一,自訂 Hook 的本質是什麼?第二,怎麼從現有的元件程式碼抽出 Hook?第三,Hook 之間怎麼組合?第四,Hook 怎麼回傳資料才容易用?第五,常見的命名與依賴踩雷。我們會延續昨天的 usePersistentDraft,把它升級成「支援驗證、多欄位、跨分頁同步」的版本,並用 useDebounce、useToggle、useFetch 演示「組合 Hook」的威力。讀完之後你應該能判斷「什麼邏輯該做成 Hook、什麼邏輯該留在元件裡」。整篇閱讀時間約 30 分鐘,動手做大約 35 分鐘。
自訂 Hook 的本質
自訂 Hook 就是一個「呼叫了其他 Hook 的函式」。它不需要任何特殊的 return 型別、不需要繼承某個類別、不需要註冊到 React 內部。只要遵守命名規則,React 函式元件就可以像呼叫普通函式一樣呼叫它。下面是最小的範例:
// src/hooks/useToggle.ts
// 最小的自訂 Hook:把布林值的切換邏輯抽出來
import { useState } from "react";
export function useToggle(initial: boolean = false) {
const [value, setValue] = useState(initial);
// 切換函式:呼叫時把布林值反過來
const toggle = () => setValue((v) => !v);
// 回傳 [目前值, 切換函式, 強制設值函式],用法類似 useState
return [value, toggle, setValue] as const;
}
這個 Hook 看起來跟寫在元件裡的程式碼沒兩樣,差別在於它「被抽出來、可以重複使用」。元件裡如果呼叫 useToggle(),React 內部會把它視為「這個元件呼叫了 useState」——換句話說,自訂 Hook 只是「把多個 Hook 的呼叫打包成一個函式」。這也意味著:Hook 內部的 state 是「每次呼叫各自獨立」的。同一個元件裡呼叫兩次 useToggle,會得到兩個獨立的布林值。
為什麼要命名為 use 開頭?因為 React 的 ESLint 規則(react-hooks/rules-of-hooks)只掃描所有 use 開頭的函式,並檢查「Hook 不能在條件式、迴圈、巢狀函式裡呼叫」。如果你的函式沒以 use 開頭,linter 不會檢查它;呼叫順序錯了就會出 bug。所以命名不是裝飾品,是 linter 的觸發條件,也是 React 社群辨識「這是 Hook」的訊號。
Hook 不是元件
新手常誤把 Hook 當元件寫,導致「Hook 回傳 JSX」這種奇怪的程式碼。Hook 回傳的應該是「資料與函式」,由元件決定怎麼把這些資料變成畫面。如果你的 Hook 包含 JSX,那它就是元件,應該用 .tsx 寫成 React component 而不是 Hook:
// 反例:Hook 不該回傳 JSX(會把資料邏輯跟畫面綁死)
function useUserCard() {
const user = useUser();
return <div>{user.name}</div>; // 這不是 Hook,這應該是 UserCard 元件
}
// 正例:Hook 回傳資料,元件負責渲染
function useUser() {
const [user, setUser] = useState<User | null>(null);
return { user, setUser };
}
function UserCard() {
const { user } = useUser();
return <div>{user?.name ?? "載入中"}</div>;
}
分辨的方法很簡單:寫完之後問自己「這段邏輯要不要跟畫面綁在一起?」要,就寫成元件;不要,就寫成 Hook。Hook 的目的是「把狀態邏輯抽離畫面」,元件的目的是「把畫面跟資料綁定」。一個常見的判斷口訣是:Hook 裡不該出現 return 帶 JSX 的寫法(也就是類似 return ),只要出現就是元件。
從元件抽出 Hook 的標準流程
把昨天的 usePersistentDraft 重新看一下,思考「如果第二個元件也想用同樣的自動儲存功能,要怎麼重用?」實務上的標準流程是三步:
- 找出「純邏輯」的部分(state 宣告、effect 設定、cleanup 邏輯)。
- 把這些邏輯搬到一個以
use開頭的函式。 - 把函式需要的「輸入」當參數傳入、回傳「輸出」(state 與 setter)。
昨天我們已經寫了 usePersistentDraft 的雛形。今天把它升級成「支援驗證、跨分頁同步、自訂序列化」的版本。第一個範例是升級版的 usePersistentState,搭配 Zod 做 schema 驗證:
// src/hooks/usePersistentState.ts
// 自訂 Hook:把任意可序列化 state 同步到 localStorage,並用 Zod 驗證
import { useEffect, useState } from "react";
import { z } from "zod";
type Options<T> = {
// 驗證 schema:若提供,從 localStorage 讀回的資料會被驗證
schema?: z.ZodType<T>;
// 序列化函式(預設 JSON.stringify)
serialize?: (value: T) => string;
// 反序列化函式(預設 JSON.parse)
deserialize?: (raw: string) => unknown;
};
const defaultSerialize = (v: unknown) => JSON.stringify(v);
const defaultDeserialize = (raw: string) => JSON.parse(raw);
export function usePersistentState<T>(
key: string,
initial: T,
options: Options<T> = {},
) {
const {
schema,
serialize = defaultSerialize,
deserialize = defaultDeserialize,
} = options;
// 延遲初始化:第一次 render 時才讀 localStorage(避免 SSR 出錯)
const [value, setValue] = useState<T>(() => {
if (typeof window === "undefined") return initial;
const raw = window.localStorage.getItem(key);
if (raw === null) return initial;
try {
const parsed = deserialize(raw);
// 用 schema 驗證讀回的資料;驗證失敗就退回 initial
if (schema) {
const result = schema.safeParse(parsed);
return result.success ? result.data : initial;
}
return parsed as T;
} catch {
return initial;
}
});
// 每次 value 改變時同步寫回
useEffect(() => {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(key, serialize(value));
} catch (err) {
console.warn("usePersistentState 寫入失敗", err);
}
}, [key, value, serialize]);
return [value, setValue] as const;
}
這個 Hook 把昨天的範例擴充成「泛型搭配驗證與序列化自訂」。在元件裡使用時,只要傳入 key、初始值、schema,就能拿到一組「會自動同步到 localStorage」的 state。特別注意兩個設計細節:第一,泛型 T 讓 Hook 可以承載任何形狀的資料,第二參數 initial 的型別會自動推導;第二,serialize 與 deserialize 函式用 options 物件包起來,未來要支援 base64、壓縮、自訂日期格式都不用改 Hook 本體。
在元件裡使用就非常乾淨:
// src/components/NoteEditor.tsx(升級版)
// 多欄位自動儲存:用 usePersistentState 儲存整份草稿物件
import { z } from "zod";
import { usePersistentState } from "../hooks/usePersistentState";
const DraftSchema = z.object({
title: z.string().min(1, "標題不可為空").max(100),
body: z.string().max(5000),
});
type Draft = z.infer<typeof DraftSchema>;
const emptyDraft: Draft = { title: "", body: "" };
export function NoteEditor() {
const [draft, setDraft] = usePersistentState<Draft>("note:draft", emptyDraft, {
schema: DraftSchema,
});
return (
<form className="mx-auto max-w-xl space-y-3 p-4">
<label className="block">
<span className="text-sm font-medium text-slate-700">標題</span>
<input
type="text"
value={draft.title}
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
className="mt-1 w-full rounded border border-slate-300 p-2 text-sm"
/>
</label>
<label className="block">
<span className="text-sm font-medium text-slate-700">內容</span>
<textarea
value={draft.body}
onChange={(e) => setDraft({ ...draft, body: e.target.value })}
rows={6}
className="mt-1 w-full rounded border border-slate-300 p-2 text-sm"
/>
</label>
<p className="text-xs text-slate-500">
目前標題 {draft.title.length} 字、內容 {draft.body.length} 字(重整後仍會保留)
</p>
</form>
);
}
這段範例展示「自訂 Hook 重用邏輯」的核心價值:NoteEditor 不再碰任何 useEffect、localStorage、try/catch,全部交給 usePersistentState 處理。如果明天要再加一個「自動儲存使用者偏好」的元件,只要呼叫 usePersistentState 傳不同的 key 即可,零程式碼複製。
跨分頁同步:把 storage 事件串進來
localStorage 有一個特性:在同一個瀏覽器的不同分頁之間,「storage」事件會自動觸發。這意味著如果某個分頁改了 localStorage 的某個 key,其他分頁可以監聽到這個事件。我們可以在 Hook 裡訂閱這個事件,讓多個分頁的 state 保持同步:
// src/hooks/usePersistentState.ts(擴充版)
// 在上一版的基礎上,加上跨分頁同步
import { useEffect, useState } from "react";
import { z } from "zod";
export function usePersistentState<T>(
key: string,
initial: T,
options: Options<T> = {},
) {
const { schema, serialize = defaultSerialize, deserialize = defaultDeserialize } = options;
const [value, setValue] = useState<T>(() => readInitial(key, initial, schema, deserialize));
// 寫入:value 改變時同步寫回 localStorage
useEffect(() => {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(key, serialize(value));
} catch (err) {
console.warn("usePersistentState 寫入失敗", err);
}
}, [key, value, serialize]);
// 跨分頁同步:當其他分頁改了同一個 key,把 value 也更新
useEffect(() => {
if (typeof window === "undefined") return;
const onStorage = (e: StorageEvent) => {
if (e.key !== key || e.newValue === null) return;
try {
const parsed = deserialize(e.newValue);
const next = schema ? (schema.safeParse(parsed).data ?? initial) : (parsed as T);
setValue(next);
} catch {
// 忽略解析失敗
}
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, [key, schema, deserialize, initial]);
return [value, setValue] as const;
}
這個擴充版多了「跨分頁同步」的能力:在 A 分頁編輯草稿、B 分頁會自動更新成同一份內容。實務上很適合「同個使用者在多個分頁開著同一個後台」的情境。注意 storage 事件只在「其他分頁」觸發,自己寫入的分頁不會收到——所以不需要擔心「自己寫入又自己 setState 造成迴圈」。
useDebounce:用一個 Hook 解決「打字時不要每個字都打 API」
另一個常見的自訂 Hook 是 useDebounce。它的用途是「把快速變化的值『延遲』成一個穩定的值」:例如使用者打字時,搜尋框的值每秒可能變 5 次,但 API 不需要被打 5 次——只要在使用者停下來 300 毫秒之後才打一次就好。useDebounce 把這個邏輯封裝起來:
// src/hooks/useDebounce.ts
// 自訂 Hook:延遲一段時間才更新值
import { useEffect, useState } from "react";
export function useDebounce<T>(value: T, delayMs: number = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
// 設定一個計時器:delayMs 後才把 debounced 更新成 value
const id = setTimeout(() => setDebounced(value), delayMs);
// cleanup:下次 effect 跑或元件被移除時,清掉這個計時器
return () => clearTimeout(id);
}, [value, delayMs]);
return debounced;
}
這個 Hook 用到「昨天的 useEffect 規則」:每次 value 變化時啟動計時器,並在 cleanup 中清除。這樣當 value 連續變化時,只有最後一次的計時器會真正執行,中間的都被 cleanup 清掉。使用方式很簡單——把「原始值」傳進去,拿到「延遲後的值」:
// src/components/SearchBox.tsx
// 搜尋框:使用者停止打字 300ms 後才打 API
import { useEffect, useState } from "react";
import { useDebounce } from "../hooks/useDebounce";
export function SearchBox({ onSearch }: { onSearch: (q: string) => void }) {
const [keyword, setKeyword] = useState("");
const debounced = useDebounce(keyword, 300); // 300ms 後才更新
// 當 debounced 變了才打 API(避免每打一個字就送一次)
useEffect(() => {
if (!debounced) return;
onSearch(debounced);
}, [debounced, onSearch]);
return (
<input
type="search"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="輸入關鍵字"
className="w-full rounded border border-slate-300 p-2 text-sm"
/>
);
}
這個範例展示「Hook 內部使用另一個 Hook」——useDebounce 內部呼叫了 useState 與 useEffect,元件裡又呼叫 useDebounce 與 useEffect。每次元件 render 時,Hook 的呼叫順序都一樣(先是 useState、再是 useDebounce、再是 useEffect),符合 Hook 規則。
Hook 的回傳值設計紀律
自訂 Hook 的回傳方式沒有強制規定,但有幾個常見的模式可以選。選對模式能讓呼叫端更舒服,選錯會讓元件讀起來很彆扭。下面三種是 React 社群最常用的:
- 元組(tuple):回傳
[value, setter],像 useState 一樣。適合「主要回傳一個值」的 Hook,例如useToggle、useDebounce。 - 物件(object):回傳
{ value, setter, extra },適合「同時回傳多個相關欄位」的 Hook,例如useFetch回傳{ data, error, isLoading }。 - 只有副作用:不回傳值,只在某個值改變時做事。例如
useDocumentTitle(title),每次 title 變化就把 document.title 同步過去。
實務上的選擇原則:「只有一個主要輸出」用元組;「多個獨立欄位」用物件;「純粹副作用」不需要回傳值。元組的優點是「呼叫端可以自己命名」,例如 const [keyword, setKeyword] = useDebounce(...);物件的優點是「呼叫端可以一次拿到多個東西,但欄位名稱被 Hook 綁死」。
下面是 useFetch 範例,用物件回傳三個欄位:
// src/hooks/useFetch.ts
// 自訂 Hook:抓取 JSON 資料,回傳 data / error / loading
import { useEffect, useState } from "react";
type FetchState<T> = {
data: T | null;
error: Error | null;
loading: boolean;
};
export function useFetch<T>(url: string): FetchState<T> {
const [state, setState] = useState<FetchState<T>>({
data: null,
error: null,
loading: true,
});
useEffect(() => {
const ctrl = new AbortController();
let cancelled = false;
async function run() {
setState({ data: null, error: null, loading: true });
try {
const res = await fetch(url, { signal: ctrl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as T;
if (!cancelled) setState({ data, error: null, loading: false });
} catch (err) {
if (cancelled) return;
setState({
data: null,
error: err instanceof Error ? err : new Error("Unknown"),
loading: false,
});
}
}
run();
return () => {
cancelled = true;
ctrl.abort();
};
}, [url]);
return state;
}
使用方式:
// src/components/UserList.tsx
import { useFetch } from "../hooks/useFetch";
type User = { id: number; name: string };
export function UserList() {
const { data, error, loading } = useFetch<User[]>("/api/users");
if (loading) return <p className="text-slate-500">載入中…</p>;
if (error) return <p className="text-red-600">錯誤:{error.message}</p>;
if (!data) return null;
return (
<ul className="space-y-1">
{data.map((u) => (
<li key={u.id} className="rounded border border-slate-200 p-2 text-sm">
{u.name}
</li>
))}
</ul>
);
}
注意 useFetch 用了 cancelled 旗標(昨天 useEffect 章節提過的技巧):元件被移除時,正在進行的 fetch 即使回來,也不會 setState。Day 14 會介紹更強大的 TanStack Query,這裡的 useFetch 主要是為了展示「Hook 怎麼回傳物件」。
常見錯誤與踩雷
第一個踩雷是「Hook 名稱不以 use 開頭」。React 的 linter 只檢查以 use 開頭的函式,所以如果你命名為 fetchUser() 卻在裡面呼叫 useState,linter 不會幫你抓「在條件式裡呼叫」這種 bug。修法很簡單:只要函式裡有用到 Hook,名稱就要以 use 開頭。
第二個踩雷是「Hook 在條件式或迴圈裡被呼叫」。這是 Hook 的根本禁令——React 用「呼叫順序」來區分多次呼叫的結果,如果在條件式裡呼叫,順序會亂掉,所有 state 對應關係就錯了:
// 反例:條件式呼叫 Hook(會壞掉)
function Form({ enabled }: { enabled: boolean }) {
if (enabled) {
const [value, setValue] = useState(""); // 違反 Hook 規則
}
// 後續邏輯會拿到錯的 value
return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}
// 正解:把條件移到 Hook 內部
function Form({ enabled }: { enabled: boolean }) {
const [value, setValue] = useState("");
// 用 enabled 控制副作用或行為,不要控制 Hook 本身
useEffect(() => {
if (enabled) doSomething(value);
}, [enabled, value]);
return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}
第三個踩雷是「Hook 回傳不穩定的參考」。如果你的 Hook 回傳一個「每次 render 都被重新產生的物件或函式」,會導致呼叫端的 useEffect 與 useMemo 一直以為依賴變了。修法是用 useMemo 或 useCallback 包起來,或在 Hook 內部就把物件組好:
// 反例:每次 render 都回傳新物件(呼叫端的 useEffect 會無限觸發)
function useUser() {
const [user, setUser] = useState(null);
return { user, setUser }; // 新物件,每次 render 都「變」
}
// 正解:把物件用 useMemo 包起來
function useUser() {
const [user, setUser] = useState(null);
return useMemo(() => ({ user, setUser }), [user]);
}
第四個踩雷是「Hook 之間互相耦合」。例如 useBookingFlow() 內部用了 useUser(),useUser() 內部又用了 usePermission()。這種層層依賴的 Hook 寫起來容易,除錯時很難定位是哪一層壞了。設計原則:Hook 之間的依賴不要超過兩層,否則就該考慮「合併成一個 Hook」或「把通用邏輯拆出來」。
第五個踩雷是「Hook 沒處理 SSR」。在 Next.js 裡,如果你的 Hook 直接呼叫 window.localStorage 而沒用 typeof window !== "undefined" 判斷,伺服器渲染時會丟出例外。今天的 usePersistentState 範例已經預先判斷過,搬到 Next.js 時不會壞。
效能與實務提醒
自訂 Hook 對效能的影響主要是「重新渲染」。React 不會因為「元件呼叫了自訂 Hook」就多 re-render 一次——Hook 內部的 useState 跟元件直接呼叫 useState 一樣,都是「這個元件多一個 state」。但如果你的 Hook 內部用了 useMemo 或 useCallback,會增加一點額外的依賴比對成本,通常可以忽略不計。
另一個實務提醒是「Hook 測試」。自訂 Hook 的測試方式比元件簡單:用 React Testing Library 的 renderHook 工具(Day 15 會詳細教)就能像測試元件一樣測 Hook。重點是「讓 Hook 的依賴(fetch、storage、計時器)可以 mock」。今天的 usePersistentState 在測試時可以 mock localStorage,useFetch 可以 mock fetch 函式,useDebounce 可以用 fake timers 控制時間。
最後一個提醒是「不要過度抽象」。並不是每一段 useState 都該抽成 Hook。如果某段邏輯只在一個元件裡用一次,抽成 Hook 只會增加檔案數、降低可讀性。等到「同一段邏輯在第二個元件出現」或「邏輯複雜到影響元件可讀性」時,再抽 Hook 即可。常見的判斷指標是「這個函式被兩個以上檔案 import」。本系列後續 Day 16「專案結構」會再討論這個取捨。
順帶提一個團隊協作的實務:「Hook 集中放在 src/hooks/」。這個資料夾就是「團隊的邏輯函式庫」。每個 Hook 一個檔案,名稱就是檔名(useToggle.ts、useDebounce.ts),使用端用 import { useToggle } from "@/hooks/useToggle" 引用。如果專案用 Next.js,可以用 TypeScript path alias(@/hooks/*)減少相對路徑。當新成員加入時,只要看 hooks 資料夾就能快速掌握「這個專案有哪些可重用的邏輯」。
小結
今天我們把自訂 Hook 從觀念到實作一次走完。重點回顧:自訂 Hook 是「呼叫其他 Hook 的函式」,命名以 use 開頭,回傳資料而非 JSX;從元件抽出 Hook 的標準流程是「找邏輯 → 搬到 use 函式 → 傳輸入回輸出」;常見的回傳模式有元組、物件、純副作用三種,根據「主要輸出數量」選擇;常見踩雷包括名稱不以 use 開頭、條件式呼叫、回傳不穩定參考、過度耦合。我們也實作了 usePersistentState、useDebounce、useToggle、useFetch 四個範例 Hook,示範了「Hook 內組合 Hook」的實務做法。明天 Day 11 會進入狀態管理的主題——Context API 怎麼用、什麼時候該用、什麼時候不該用,以及「提升狀態」這個經典模式怎麼用在我們的預約系統上。
結語
明天,我們會把狀態管理的範圍從「元件內部」擴展到「跨元件」。Day 11 會介紹 React 的 Context API,示範怎麼用它傳遞「目前使用者」、「主題」、「語系」這類跨多層元件的狀態,並討論它的限制與替代方案(props drilling、提升狀態、useReducer、外部狀態函式庫)。記得今天的 usePersistentState 與 useFetch 先留著,明天會在同一個專案上把它們組合成「全域使用者狀態 + 持久化」的版本。
延伸資源
- React 官方〈Reusing Logic with Custom Hooks〉(19.x,2026 年 3 月):
https://react.dev/learn/reusing-logic-with-custom-hooks - React 官方〈Hook 規則〉:
https://react.dev/reference/rules/rules-of-hooks - Zod 官方說明(Day 9/10 範例使用):
https://zod.dev/ - useHooks 社群範例集:
https://usehooks.com/ - React Hooks ESLint 插件:
https://www.npmjs.com/package/eslint-plugin-react-hooks
留言
張貼留言