TypeScript 課程 – React + TypeScript(實務應用)
主題:自訂 Hook 型別
簡介
在 React 開發中,Hook 已成為管理狀態與副作用的標準寫法。當我們把 Hook 與 TypeScript 結合時,除了能享受到型別安全的好處,還能在編寫自訂 Hook 時得到更好的開發體驗與可維護性。
對於 初學者 來說,了解如何為自訂 Hook 正確地寫型別是避免未來 bug 的關鍵;對 中級開發者,則是提升程式碼可讀性、重用性與團隊協作效率的必備技巧。本文將從概念說明、實作範例、常見陷阱到最佳實踐,一步步帶你掌握「自訂 Hook 型別」的核心要點,讓你在專案中能夠 自信地寫出型別完整的 Hook。
核心概念
1. 為什麼要為 Hook 加上型別?
- 型別推斷:TypeScript 能自動推斷 Hook 回傳值的型別,減少手動宣告的繁瑣。
- IDE 補完:正確的型別讓 VSCode 等編輯器提供更完整的自動完成與跳轉功能。
- 防止錯誤:在使用 Hook 時,錯誤的參數或錯誤的回傳值會在編譯階段被捕捉,提高程式碼品質。
小提醒:即使是簡單的
useState,如果不加上型別,TypeScript 仍會根據初始值推斷型別,但在複雜情境(如泛型、聯合型別)下,明確宣告型別才是安全之道。
2. 基本語法與型別推斷
import { useState, useEffect } from 'react';
// 沒有寫型別,TS 會根據初始值推斷
export function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
const inc = () => setCount(c => c + 1);
return { count, inc };
}
在上例中,count 會被推斷為 number,inc 為 () => void。然而,一旦 initial 可能是 undefined 或其他型別,推斷就會失效,此時需要手動指定型別。
3. 為 Hook 加上泛型(Generic)
當 Hook 需要根據傳入參數的型別改變回傳型別時,使用泛型是最直接的解法。
import { useState } from 'react';
/**
* useLocalStorage
* 把任意型別的資料寫入 localStorage,並自動同步 state
*/
export function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
const json = window.localStorage.getItem(key);
return json ? (JSON.parse(json) as T) : initialValue;
});
const setStoredValue = (newValue: T) => {
setValue(newValue);
window.localStorage.setItem(key, JSON.stringify(newValue));
};
return [value, setStoredValue] as const; // 回傳 tuple
}
重點說明
useLocalStorage使用了<T>泛型,讓使用者在呼叫時自行決定資料型別。as const讓回傳的 tuple 被視為[T, (v: T) => void],避免被展開成Array。
使用方式:
const [name, setName] = useLocalStorage<string>('userName', 'Guest');
此時 name 的型別就是 string,如果忘記傳入泛型,TypeScript 仍會根據 initialValue 推斷。
4. 回傳 Tuple 與 Object 的型別差異
React 官方的 useState 回傳 Tuple([state, setState]),而自訂 Hook 可以自由選擇回傳 Object 或 Tuple。兩者的型別寫法略有不同:
4.1 回傳 Tuple(常見於「類似 useState」的 Hook)
export function useToggle(initial: boolean = false): [boolean, () => void] {
const [on, setOn] = useState(initial);
const toggle = () => setOn(prev => !prev);
return [on, toggle];
}
4.2 回傳 Object(易於擴充與解構)
export function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetch(url)
.then(res => res.json())
.then(json => setData(json as T))
.catch(err => setError(err))
.finally(() => setLoading(false));
}, [url]);
return { data, loading, error };
}
選擇建議:若 Hook 僅返回兩個值且語意單一,使用 Tuple;若回傳多個相關狀態(如 loading、error、data),建議使用 Object,以提升可讀性。
5. 多參數與可選參數的型別
interface UseDebounceOptions {
wait?: number; // 防抖延遲時間,預設 300ms
leading?: boolean; // 是否在延遲開始時立即觸發
}
/**
* useDebounce
* 將任意值防抖,返回最新的防抖後值
*/
export function useDebounce<T>(value: T, options: UseDebounceOptions = {}): T {
const { wait = 300, leading = false } = options;
const [debounced, setDebounced] = useState<T>(value);
const handler = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
if (leading && !handler.current) {
setDebounced(value);
}
clearTimeout(handler.current);
handler.current = setTimeout(() => {
setDebounced(value);
handler.current = undefined;
}, wait);
return () => clearTimeout(handler.current);
}, [value, wait, leading]);
return debounced;
}
要點
options為可選參數,使用介面UseDebounceOptions定義每個屬性的型別與預設值。handler使用useRef保存setTimeout回傳的 ID,型別為ReturnType<typeof setTimeout>,能在不同執行環境(Node vs Browser)保持正確。
6. 讓 Hook 支援 React Context(型別傳遞)
import { createContext, useContext, ReactNode } from 'react';
interface AuthContextValue {
user: { id: string; name: string } | null;
login: (name: string) => void;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthContextValue['user']>(null);
const login = (name: string) => setUser({ id: Date.now().toString(), name });
const logout = () => setUser(null);
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
/**
* 自訂 Hook:取得 AuthContext
* 若 context 為 undefined,拋出明確錯誤訊息
*/
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth 必須在 AuthProvider 內使用');
}
return context;
}
透過 useAuth,呼叫端只需要:
const { user, login, logout } = useAuth();
而不必關心 Context 的型別細節,型別安全與錯誤提示 完全交給 Hook 本身。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
| 缺少回傳型別 | 未為 Hook 明確宣告回傳型別,導致使用端得不到補完或產生 any。 |
使用 : ReturnType、as const 或直接在函式簽名寫明型別。 |
| 泛型未限定 | 泛型 <T> 沒有限制,使用者可以傳入不相容的型別,執行時出錯。 |
為泛型加上 extends 限制,例如 <T extends object>。 |
| Context 為 undefined | 直接 useContext 取得的值可能是 undefined,若不檢查會在執行時拋錯。 |
在自訂 Hook 中加入 runtime guard(如上例 useAuth)。 |
| 副作用依賴遺漏 | useEffect 中的依賴陣列忘記加入某個變數,導致 stale closure。 |
使用 ESLint react-hooks/exhaustive-deps 規則,或手動列出所有依賴。 |
| 返回 mutable 物件 | 直接回傳 state 物件會讓使用端意外修改,破壞不可變性。 |
使用 Object.freeze 或回傳 readonly 型別,例如 Readonly<T>。 |
最佳實踐
- 盡量使用
as const:返回 tuple 時,讓 TypeScript 推斷為 readonly,防止解構時的誤用。 - 為 Hook 加上 JSDoc:即使 TypeScript 已有型別,加入說明能提升團隊文件一致性。
- 分離型別與實作:若型別較複雜,建議放在
types.ts中,保持 Hook 檔案的簡潔。 - 使用
React.FC只在需要 children 時:自訂 Hook 不需要React.FC,保持純函式的概念。 - 測試型別:利用
tsd或type-tests撰寫型別測試,確保未來改動不會破壞 API。
實際應用場景
1. 表單驗證 Hook (useForm)
在大型表單中,我們常需要 動態欄位、即時驗證、錯誤訊息。透過泛型與物件型別,我們可以寫出一次能適用多種表單結構的 Hook。
type Validator<T> = (value: T) => string | undefined;
export function useForm<T extends Record<string, any>>(initial: T, validators: {
[K in keyof T]?: Validator<T[K]>;
}) {
const [values, setValues] = useState<T>(initial);
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
const handleChange = (field: keyof T) => (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value as any;
setValues(prev => ({ ...prev, [field]: newValue }));
const validator = validators[field];
if (validator) {
const err = validator(newValue);
setErrors(prev => ({ ...prev, [field]: err }));
}
};
const isValid = Object.values(errors).every(err => err === undefined);
return { values, errors, handleChange, isValid };
}
使用:
interface LoginForm {
email: string;
password: string;
}
const emailValidator = (v: string) => /\S+@\S+\.\S+/.test(v) ? undefined : 'Email 格式不正確';
const { values, errors, handleChange, isValid } = useForm<LoginForm>(
{ email: '', password: '' },
{ email: emailValidator }
);
好處:
values、errors、handleChange的型別全部由LoginForm推斷,開發時不會出現「找不到屬性」的錯誤。
2. 無限滾動 Hook (useInfiniteScroll)
在列表頁面常見「滾動到底自動載入」的需求。透過 泛型,我們可以讓 Hook 支援任何資料型別。
export function useInfiniteScroll<T>(fetchFn: (page: number) => Promise<T[]>, options?: {
threshold?: number;
}) {
const { threshold = 300 } = options ?? {};
const [page, setPage] = useState(0);
const [data, setData] = useState<T[]>([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const loadMore = async () => {
if (loading || !hasMore) return;
setLoading(true);
const newData = await fetchFn(page + 1);
setData(prev => [...prev, ...newData]);
setPage(prev => prev + 1);
setHasMore(newData.length > 0);
setLoading(false);
};
useEffect(() => {
const onScroll = () => {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - threshold) {
loadMore();
}
};
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, [threshold, loading, hasMore, page]);
return { data, loading, hasMore, loadMore };
}
使用:
type Post = { id: number; title: string };
const { data: posts, loading, loadMore } = useInfiniteScroll<Post>(async (p) => {
const res = await fetch(`/api/posts?page=${p}`);
return res.json();
});
3. 多語系切換 Hook (useI18n)
在國際化專案中,我們常需要 即時切換語系,且語系檔案型別會隨專案擴充。
type Locale = 'en' | 'zh-TW';
export function useI18n<T extends Record<string, string>>(defaultLocale: Locale, resources: {
[K in Locale]: T;
}) {
const [locale, setLocale] = useState<Locale>(defaultLocale);
const t = useCallback((key: keyof T) => resources[locale][key], [locale, resources]);
return { locale, setLocale, t };
}
使用:
const resources = {
en: { welcome: 'Welcome', logout: 'Logout' },
'zh-TW': { welcome: '歡迎', logout: '登出' },
};
const { locale, setLocale, t } = useI18n(resources['zh-TW'], resources);
console.log(t('welcome')); // 依照 locale 輸出對應文字
總結
自訂 Hook 在 React + TypeScript 的開發流程中扮演 提升重用性、統一行為與確保型別安全 的重要角色。本文從 為何需要型別、泛型與 Tuple/Object 回傳、多參數與 Context,到 實務範例、常見陷阱 與 最佳實踐,完整說明了「自訂 Hook 型別」的核心概念與實作技巧。
核心要點回顧
- 明確宣告回傳型別,避免
any滲入。- 善用泛型,讓 Hook 能適應不同資料結構。
- 使用
as const、readonly保障 tuple 的不可變性。- 加入 runtime guard(如 Context)提升安全性。
- 遵循 ESLint 規則、撰寫型別測試,確保長期維護性。
掌握這些技巧後,你就能在專案中 快速打造型別安全、易於維護的自訂 Hook,提升團隊開發效率,同時減少因型別錯誤所帶來的除錯成本。祝你在 React + TypeScript 的旅程中寫出更乾淨、更可靠的程式碼! 🚀