本文 AI 產出,尚未審核

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 會被推斷為 numberinc() => 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. 回傳 TupleObject 的型別差異

React 官方的 useState 回傳 Tuple[state, setState]),而自訂 Hook 可以自由選擇回傳 ObjectTuple。兩者的型別寫法略有不同:

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 使用 : ReturnTypeas 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>

最佳實踐

  1. 盡量使用 as const:返回 tuple 時,讓 TypeScript 推斷為 readonly,防止解構時的誤用。
  2. 為 Hook 加上 JSDoc:即使 TypeScript 已有型別,加入說明能提升團隊文件一致性。
  3. 分離型別與實作:若型別較複雜,建議放在 types.ts 中,保持 Hook 檔案的簡潔。
  4. 使用 React.FC 只在需要 children 時:自訂 Hook 不需要 React.FC,保持純函式的概念。
  5. 測試型別:利用 tsdtype-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 }
);

好處valueserrorshandleChange 的型別全部由 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 型別」的核心概念與實作技巧。

核心要點回顧

  1. 明確宣告回傳型別,避免 any 滲入。
  2. 善用泛型,讓 Hook 能適應不同資料結構。
  3. 使用 as constreadonly 保障 tuple 的不可變性。
  4. 加入 runtime guard(如 Context)提升安全性。
  5. 遵循 ESLint 規則、撰寫型別測試,確保長期維護性。

掌握這些技巧後,你就能在專案中 快速打造型別安全、易於維護的自訂 Hook,提升團隊開發效率,同時減少因型別錯誤所帶來的除錯成本。祝你在 React + TypeScript 的旅程中寫出更乾淨、更可靠的程式碼! 🚀