14.1 什麼是自訂 Hook?

自訂 Hook 是以 use 開頭的函式,用來封裝和複用有狀態的邏輯。

為什麼需要自訂 Hook?

// 多個元件都需要:
// 1. 呼叫 API + loading/error 處理
// 2. 監聽視窗大小
// 3. 管理表單驗證
// 4. 處理 localStorage

// → 提取成自訂 Hook,一次寫好,到處使用!

規則

  1. 名稱必須以 use 開頭
  2. 只能在元件或其他 Hook 的最上層呼叫
  3. 可以使用任何內建 Hook

14.2 常見自訂 Hook 範例

useToggle — 開關切換

import { useState, useCallback } from 'react';

function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);

  const toggle = useCallback(() => setValue((v) => !v), []);
  const setTrue = useCallback(() => setValue(true), []);
  const setFalse = useCallback(() => setValue(false), []);

  return { value, toggle, setTrue, setFalse };
}

// 使用
function App() {
  const modal = useToggle(false);
  const darkMode = useToggle(false);

  return (
    <div>
      <button onClick={darkMode.toggle}>
        {darkMode.value ? '🌙 深色' : '☀️ 淺色'}
      </button>

      <button onClick={modal.setTrue}>開啟 Modal</button>
      {modal.value && (
        <div style={{
          position: 'fixed',
          top: 0, left: 0, right: 0, bottom: 0,
          backgroundColor: 'rgba(0,0,0,0.5)',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
        }}>
          <div style={{ backgroundColor: 'white', padding: '32px', borderRadius: '12px' }}>
            <h2>Modal 視窗</h2>
            <p>這是一個 Modal</p>
            <button onClick={modal.setFalse}>關閉</button>
          </div>
        </div>
      )}
    </div>
  );
}

useLocalStorage — LocalStorage 同步

import { useState, useEffect } from 'react';

function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch {
      return initialValue;
    }
  });

  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(storedValue));
    } catch (error) {
      console.error('無法寫入 localStorage:', error);
    }
  }, [key, storedValue]);

  return [storedValue, setStoredValue];
}

// 使用
function Settings() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');
  const [fontSize, setFontSize] = useLocalStorage('fontSize', 16);
  const [username, setUsername] = useLocalStorage('username', '');

  return (
    <div style={{ fontSize: `${fontSize}px` }}>
      <h2>設定(會自動保存到 localStorage)</h2>
      <div>
        <label>主題:</label>
        <select value={theme} onChange={(e) => setTheme(e.target.value)}>
          <option value="light">淺色</option>
          <option value="dark">深色</option>
        </select>
      </div>
      <div>
        <label>字體大小:{fontSize}px</label>
        <input
          type="range"
          min="12"
          max="24"
          value={fontSize}
          onChange={(e) => setFontSize(Number(e.target.value))}
        />
      </div>
      <div>
        <label>使用者名稱:</label>
        <input value={username} onChange={(e) => setUsername(e.target.value)} />
      </div>
    </div>
  );
}

useFetch — API 資料獲取

import { useState, useEffect } from 'react';

function useFetch(url, options = {}) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let isCancelled = false;
    const controller = new AbortController();

    const fetchData = async () => {
      setLoading(true);
      setError(null);

      try {
        const response = await fetch(url, {
          ...options,
          signal: controller.signal,
        });

        if (!response.ok) {
          throw new Error(`HTTP ${response.status}: ${response.statusText}`);
        }

        const json = await response.json();

        if (!isCancelled) {
          setData(json);
        }
      } catch (err) {
        if (!isCancelled && err.name !== 'AbortError') {
          setError(err.message);
        }
      } finally {
        if (!isCancelled) {
          setLoading(false);
        }
      }
    };

    fetchData();

    return () => {
      isCancelled = true;
      controller.abort();
    };
  }, [url]);

  return { data, loading, error };
}

// 使用
function UserList() {
  const { data: users, loading, error } = useFetch(
    'https://jsonplaceholder.typicode.com/users'
  );

  if (loading) return <p>載入中...</p>;
  if (error) return <p style={{ color: 'red' }}>錯誤:{error}</p>;

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name} — {user.email}</li>
      ))}
    </ul>
  );
}

useDebounce — 防抖

import { useState, useEffect } from 'react';

function useDebounce(value, delay = 500) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

// 使用
function SearchBar() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 300);

  useEffect(() => {
    if (debouncedQuery) {
      console.log('搜尋:', debouncedQuery);
    }
  }, [debouncedQuery]);

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

useWindowSize — 視窗尺寸

import { useState, useEffect } from 'react';

function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  useEffect(() => {
    const handleResize = () => {
      setSize({ width: window.innerWidth, height: window.innerHeight });
    };

    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return size;
}

// 使用
function ResponsiveComponent() {
  const { width } = useWindowSize();

  return (
    <div>
      <p>視窗寬度:{width}px</p>
      {width < 768 ? (
        <p>手機版佈局</p>
      ) : width < 1024 ? (
        <p>平板版佈局</p>
      ) : (
        <p>桌面版佈局</p>
      )}
    </div>
  );
}

useForm — 表單管理

import { useState, useCallback } from 'react';

function useForm(initialValues, validate) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});

  const handleChange = useCallback((e) => {
    const { name, value, type, checked } = e.target;
    setValues((prev) => ({
      ...prev,
      [name]: type === 'checkbox' ? checked : value,
    }));
  }, []);

  const handleBlur = useCallback((e) => {
    const { name } = e.target;
    setTouched((prev) => ({ ...prev, [name]: true }));
    if (validate) {
      setErrors(validate(values));
    }
  }, [values, validate]);

  const handleSubmit = useCallback((onSubmit) => (e) => {
    e.preventDefault();
    const allTouched = Object.keys(values).reduce(
      (acc, key) => ({ ...acc, [key]: true }),
      {}
    );
    setTouched(allTouched);

    const validationErrors = validate ? validate(values) : {};
    setErrors(validationErrors);

    if (Object.keys(validationErrors).length === 0) {
      onSubmit(values);
    }
  }, [values, validate]);

  const reset = useCallback(() => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
  }, [initialValues]);

  return { values, errors, touched, handleChange, handleBlur, handleSubmit, reset };
}

// 使用
function LoginForm() {
  const { values, errors, touched, handleChange, handleBlur, handleSubmit } = useForm(
    { email: '', password: '' },
    (values) => {
      const errors = {};
      if (!values.email) errors.email = 'Email 為必填';
      if (!values.password) errors.password = '密碼為必填';
      if (values.password && values.password.length < 6) {
        errors.password = '密碼至少 6 個字元';
      }
      return errors;
    }
  );

  return (
    <form onSubmit={handleSubmit((data) => console.log('提交:', data))}>
      <div>
        <input
          name="email"
          value={values.email}
          onChange={handleChange}
          onBlur={handleBlur}
          placeholder="Email"
        />
        {touched.email && errors.email && (
          <p style={{ color: 'red' }}>{errors.email}</p>
        )}
      </div>
      <div>
        <input
          name="password"
          type="password"
          value={values.password}
          onChange={handleChange}
          onBlur={handleBlur}
          placeholder="密碼"
        />
        {touched.password && errors.password && (
          <p style={{ color: 'red' }}>{errors.password}</p>
        )}
      </div>
      <button type="submit">登入</button>
    </form>
  );
}

14.3 自訂 Hook 的設計原則

原則 說明
單一職責 每個 Hook 只做一件事
命名清晰 useXxx 讓人一看就知道功能
回傳值明確 陣列(順序重要時)或物件(語義清晰時)
可配置 接受參數來調整行為
處理清除 在 useEffect 中正確清除副作用

14.4 練習題

練習 1:useClickOutside

建立一個 useClickOutside(ref, callback) Hook,當點擊 ref 元素外部時觸發 callback。

練習 2:useCounter

建立一個 useCounter(initialValue, { min, max, step }) Hook,提供 increment、decrement、reset 和 set 方法。


本課重點回顧

  1. 自訂 Hook 以 use 開頭
  2. 用來封裝和複用有狀態的邏輯
  3. 可以組合使用其他 Hook
  4. 每個元件使用自訂 Hook 都有獨立的 state
  5. 設計時注重單一職責可配置性