EP.20Web 開發系列

自訂 Hook:把邏輯從元件裡抽出來
讓元件永遠不超過 100 行

useLocalStorage、useDebounce、useFetch — 一旦學會,你的元件永遠不會超過 100 行。 Custom Hook 是 React 開發者從「寫得出來」進化到「寫得好」的關鍵一步。

Joseph Chen 2026 14 min read Custom Hooks · TypeScript · Reusability

「我的 ProfilePage、SettingsPage、DashboardPage 都有一段讀寫 localStorage 的邏輯, 複製貼上了三次,改一個地方就要改三個地方……」

這是幾乎每個初學 React 的人都會遇到的痛點。解法不是「更仔細地複製貼上」, 而是學會把重複邏輯抽成 Custom Hook。這篇會帶你從痛點出發, 一步步建立三個實用的 Custom Hook,並在最後展示如何組合它們。

痛點:三個元件寫了三次一樣的邏輯

想像這個場景:你的 app 有三個頁面,都需要把使用者偏好存到 localStorage, 並在元件掛載時讀回來。你可能會在每個元件裡寫出這樣的程式碼——

tsx
// ProfilePage.tsx — 第一次寫
function ProfilePage() {
  const [theme, setTheme] = useState('light');

  useEffect(() => {
    const saved = localStorage.getItem('theme');
    if (saved) setTheme(saved);
  }, []);

  const handleThemeChange = (newTheme: string) => {
    setTheme(newTheme);
    localStorage.setItem('theme', newTheme);
  };

  return <div>...</div>;
}

// SettingsPage.tsx — 複製貼上,略做修改
function SettingsPage() {
  const [theme, setTheme] = useState('light');

  useEffect(() => {
    const saved = localStorage.getItem('theme');
    if (saved) setTheme(saved);
  }, []);

  const handleThemeChange = (newTheme: string) => {
    setTheme(newTheme);
    localStorage.setItem('theme', newTheme); // 又是一樣的邏輯
  };

  return <div>...</div>;
}

// DashboardPage.tsx — 第三次,完全一樣
function DashboardPage() {
  const [theme, setTheme] = useState('light');

  useEffect(() => {
    const saved = localStorage.getItem('theme');
    if (saved) setTheme(saved);
  }, []);
  // ...
}

這段重複代碼有什麼問題?

  • • 邏輯改了(例如加上 JSON.parse 容錯),要在三個地方分別修改
  • • localStorage 的 key 名稱打錯,三個地方都要找
  • • 寫測試時,同樣的邏輯要測三次
  • • 新同事加入,看到三份一樣的東西,不知道哪個才是「正確版本」

解法是:把這 15 行重複邏輯「搬出來」,放到一個以 use 開頭的函式裡。 這就是 Custom Hook 的核心概念——把邏輯抽離元件,讓元件只專注在「畫什麼」

Custom Hook 的三條規則

Custom Hook 不是什麼魔法,它就是一個普通的 JavaScript/TypeScript 函式, 只是必須遵守三條規則。違反這三條規則,React 不知道怎麼正確追蹤你的狀態。

1

名稱必須以 use 開頭

React 的 linter(eslint-plugin-react-hooks)靠這個命名規則辨識 Hook。 如果你命名為 localStorageHook,它就不是 Hook, 裡面呼叫 useState 會報錯或行為異常。
✓ useLocalStorage ✓ useDebounce ✓ useFetch

2

可以在裡面呼叫其他 Hook

Custom Hook 裡面可以呼叫 useState、useEffect、useRef, 甚至呼叫其他 Custom Hook。 這讓你可以把複雜邏輯「一層一層組合」,就像積木一樣。

3

可以回傳任何東西

不像元件必須回傳 JSX,Hook 可以回傳數值、函式、物件、陣列, 或完全不回傳。回傳什麼,取決於呼叫端需要什麼。

生活類比:Custom Hook 就像一個「私人助理」。 你告訴助理「幫我追蹤 localStorage 裡的 theme 值」, 助理會自動幫你讀取、儲存、同步,並隨時回報最新值給你。 你(元件)不需要知道助理怎麼做到的,你只要用結果就好。

useLocalStorage — 第一個實用 Hook

我們來解決開頭那個痛點。把讀寫 localStorage 的邏輯抽出來, 做成一個可以重用於任何型別的泛型 Hook:

tsx
// hooks/useLocalStorage.ts
import { useState } from 'react';

function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => {
    // lazy initializer:用函式延遲執行,避免 SSR 問題
    try {
      const item = localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch {
      // localStorage 可能因隱私模式或瀏覽器限制而無法存取
      return initialValue;
    }
  });

  const setStoredValue = (newValue: T) => {
    setValue(newValue);
    localStorage.setItem(key, JSON.stringify(newValue));
  };

  return [value, setStoredValue] as const;
}

export default useLocalStorage;

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 行:

tsx
// ProfilePage.tsx — 現在只需要 1 行!
const [theme, setTheme] = useLocalStorage('theme', 'light');

// 也可以用在任何型別
const [user, setUser] = useLocalStorage('user', { name: '', email: '' });
const [cartCount, setCartCount] = useLocalStorage('cart-count', 0);
const [preferences, setPreferences] = useLocalStorage('prefs', {
  language: 'zh-TW',
  notifications: true,
  fontSize: 'medium',
});

// 用起來跟 useState 完全一樣,但會自動同步到 localStorage
function ProfilePage() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');

  return (
    <div>
      <p>目前主題:{theme}</p>
      <button onClick={() => setTheme('dark')}>切換深色模式</button>
    </div>
  );
}

三個頁面改起來了嗎?只要把各自的 useState + useEffect 換成 useLocalStorage, 就完成重構。以後 localStorage 邏輯要改,只改一個地方。

useDebounce — 搜尋框的效能救星

假設你做了一個搜尋框,每當使用者輸入,就打一次 API 搜尋結果。 聽起來很合理,但實際上——

沒有 debounce 的搜尋框(效能殺手)

tsx
function SearchPage() {
  const [search, setSearch] = useState('');

  useEffect(() => {
    // ❌ 每次 search 改變都直接 fetch!
    // 使用者輸入 "typescript" (10個字) = 10 次 API 呼叫
    // 300ms 快速連打 = 伺服器被洗版
    if (search) fetchResults(search);
  }, [search]);

  return <input onChange={e => setSearch(e.target.value)} />;
}

使用者輸入 10 個字的過程中,你發了 10 次請求。前 9 次的結果根本沒用, 卻浪費了伺服器資源和使用者的流量。

Debounce 的概念是:「等使用者停止輸入 X 毫秒後,才執行動作。」 把這個等待邏輯抽成 Hook:

tsx
// hooks/useDebounce.ts
import { useState, useEffect } from 'react';

function useDebounce<T>(value: T, delay: number = 500): T {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    // 設定 timer:delay 毫秒後更新 debouncedValue
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    // Cleanup!每次 value 或 delay 改變,先清掉舊的 timer
    // 這確保只有「最後一次輸入」才會觸發更新
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

export default useDebounce;

用起來非常直觀——加一行 useDebounce, 搜尋框就從「每字都 fetch」變成「停止輸入後才 fetch」:

tsx
function SearchPage() {
  const [search, setSearch] = useState('');

  // 把原始 search 值延遲 300ms
  const debouncedSearch = useDebounce(search, 300);

  useEffect(() => {
    // 只在停止輸入 300ms 後才觸發
    if (debouncedSearch) fetchResults(debouncedSearch);
  }, [debouncedSearch]); // 注意:依賴的是 debouncedSearch,不是 search

  return (
    <input
      value={search}
      onChange={e => setSearch(e.target.value)}
      placeholder="搜尋..."
    />
  );
}

// 效果:
// 使用者輸入 "typescript"(300ms 內快速輸入)
// → 只有在停止輸入 300ms 後,才發出 1 次 API 請求
// → 省下 9 次不必要的請求!

為什麼 Cleanup 很重要?

return () => clearTimeout(timer) 這行是關鍵。

想像使用者連續輸入三個字:aababc。 每次輸入都會觸發 useEffect,建立一個新的 timer。 如果不清掉舊的 timer,三個 timer 會「競速」, 可能造成 race condition——最後回來的結果不一定是最新的那個。 Cleanup 確保永遠只有「最後設定的那個 timer」有效。

useFetch — 把資料請求邏輯封裝起來

每個需要顯示 API 資料的元件,都需要 loading 狀態、error 狀態、data 狀態…… 每次都重複寫一樣的模板代碼。把它封裝成 useFetch:

tsx
// hooks/useFetch.ts
import { useState, useEffect } from 'react';

type FetchState<T> = {
  data: T | null;
  loading: boolean;
  error: string | null;
};

function useFetch<T>(url: string) {
  const [state, setState] = useState<FetchState<T>>({
    data: null,
    loading: true,
    error: null,
  });

  useEffect(() => {
    // AbortController:讓我們可以「取消」已發出的請求
    const controller = new AbortController();

    setState(prev => ({ ...prev, loading: true }));

    fetch(url, { signal: controller.signal })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then(data => setState({ data, loading: false, error: null }))
      .catch(err => {
        // AbortError 是我們主動取消的,不算「錯誤」
        if (err.name !== 'AbortError') {
          setState({ data: null, loading: false, error: err.message });
        }
      });

    // Cleanup:元件卸載時取消請求
    return () => controller.abort();
  }, [url]); // url 改變時重新請求

  return state;
}

export default useFetch;

AbortController 防止 Memory Leak

如果使用者快速切換頁面,元件在 API 還沒回來就卸載了。 此時如果 fetch 完成並嘗試呼叫 setState,React 會警告你「在已卸載的元件上設定狀態」, 這就是 memory leak 的前兆。

controller.abort() 會在元件卸載時取消請求,err.name !== 'AbortError' 確保主動取消不被誤判為錯誤。

有了 useFetch,任何資料顯示頁面都可以用三行解決所有狀態管理:

tsx
// 用法非常乾淨
type Post = { id: number; title: string; body: string };

function PostList() {
  const { data: posts, loading, error } = useFetch<Post[]>('/api/posts');

  if (loading) return <div>載入中...</div>;
  if (error) return <div>載入失敗:{error}</div>;
  if (!posts) return null;

  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

// url 變了自動重新請求
function UserDetail({ userId }: { userId: number }) {
  const { data: user, loading } = useFetch<User>(`/api/users/${userId}`);
  // userId 從 1 變成 2,自動重新 fetch /api/users/2
}

組合 Hooks — usePagination 綜合練習

Custom Hook 真正強大的地方,在於可以把多個 Hook 組合成更複雜的邏輯。 分頁(pagination)就是一個好例子——它需要資料、目前頁數、計算邊界, 但這些邏輯全部可以封裝在一個 Hook 裡:

tsx
// hooks/usePagination.ts
import { useState } from 'react';

function usePagination<T>(data: T[], itemsPerPage: number = 10) {
  const [currentPage, setCurrentPage] = useState(1);

  const totalPages = Math.ceil(data.length / itemsPerPage);

  const currentData = data.slice(
    (currentPage - 1) * itemsPerPage,
    currentPage * itemsPerPage
  );

  const goToPage = (page: number) => {
    // Math.max/min 確保不超出合法頁碼範圍
    setCurrentPage(Math.max(1, Math.min(page, totalPages)));
  };

  return {
    currentData,   // 目前頁要顯示的資料
    currentPage,   // 目前在第幾頁
    totalPages,    // 總共幾頁
    goToPage,      // 跳到指定頁
    hasNext: currentPage < totalPages,
    hasPrev: currentPage > 1,
  };
}

export default usePagination;

更進一步,我們可以把 useFetch 和 usePagination 結合起來, 完成一個具備「資料請求 + 分頁」功能的複合 Hook:

tsx
function PostListPage() {
  // 先用 useFetch 拿資料
  const { data: allPosts, loading, error } = useFetch<Post[]>('/api/posts');

  // 再用 usePagination 管理分頁
  const { currentData, currentPage, totalPages, goToPage, hasNext, hasPrev } =
    usePagination(allPosts ?? [], 5);

  if (loading) return <div>載入中...</div>;
  if (error) return <div>發生錯誤:{error}</div>;

  return (
    <div>
      <ul>
        {currentData.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>

      <div className="flex gap-4 mt-4">
        <button onClick={() => goToPage(currentPage - 1)} disabled={!hasPrev}>
          上一頁
        </button>
        <span>{currentPage} / {totalPages}</span>
        <button onClick={() => goToPage(currentPage + 1)} disabled={!hasNext}>
          下一頁
        </button>
      </div>
    </div>
  );
}

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 可以互相組合,從簡單邏輯構建複雜功能
Custom Hooks
useLocalStorage
useDebounce
useFetch
TypeScript
React