14.1 什麼是自訂 Hook?
自訂 Hook 是以 use 開頭的函式,用來封裝和複用有狀態的邏輯。
為什麼需要自訂 Hook?
// 多個元件都需要:
// 1. 呼叫 API + loading/error 處理
// 2. 監聽視窗大小
// 3. 管理表單驗證
// 4. 處理 localStorage
// → 提取成自訂 Hook,一次寫好,到處使用!
規則
- 名稱必須以
use開頭 - 只能在元件或其他 Hook 的最上層呼叫
- 可以使用任何內建 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 方法。
本課重點回顧
- 自訂 Hook 以
use開頭 - 用來封裝和複用有狀態的邏輯
- 可以組合使用其他 Hook
- 每個元件使用自訂 Hook 都有獨立的 state
- 設計時注重單一職責和可配置性