13.1 什麼是 useReducer?
useReducer 是 useState 的替代方案,適合管理複雜的狀態邏輯。它借鑑了 Redux 的概念。
const [state, dispatch] = useReducer(reducer, initialState);
核心概念
使用者操作 → dispatch(action) → reducer(state, action) → 新 state → UI 更新
| 概念 | 說明 |
|---|---|
| state | 目前的狀態 |
| action | 描述「發生了什麼事」的物件 { type: '...', payload: ... } |
| dispatch | 發送 action 的函式 |
| reducer | 純函式,根據 action 計算新的 state |
13.2 基本用法
useState vs useReducer 對比
// ============================================================
// 使用 useState
// ============================================================
function CounterWithState() {
const [count, setCount] = useState(0);
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={() => setCount(count - 1)}>-1</button>
<button onClick={() => setCount(0)}>重置</button>
</div>
);
}
// ============================================================
// 使用 useReducer
// ============================================================
import { useReducer } from 'react';
function counterReducer(state, action) {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 };
case 'DECREMENT':
return { count: state.count - 1 };
case 'RESET':
return { count: 0 };
case 'SET':
return { count: action.payload };
default:
throw new Error(`未知的 action: ${action.type}`);
}
}
function CounterWithReducer() {
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
return (
<div>
<p>{state.count}</p>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+1</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>-1</button>
<button onClick={() => dispatch({ type: 'RESET' })}>重置</button>
<button onClick={() => dispatch({ type: 'SET', payload: 100 })}>設為 100</button>
</div>
);
}
13.3 何時使用 useReducer?
| 場景 | 推薦 |
|---|---|
| 簡單的獨立狀態 | useState |
| 多個相關的狀態需要同步更新 | useReducer |
| 下一個 state 依賴前一個 state | useReducer |
| 複雜的狀態轉換邏輯 | useReducer |
| 多種操作(CRUD) | useReducer |
| 需要容易測試狀態邏輯 | useReducer |
13.4 完整範例:購物車
import { useReducer } from 'react';
// ============================================================
// Reducer
// ============================================================
function cartReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM': {
const existingIndex = state.items.findIndex(
(item) => item.id === action.payload.id
);
if (existingIndex >= 0) {
const newItems = state.items.map((item, index) =>
index === existingIndex
? { ...item, quantity: item.quantity + 1 }
: item
);
return { ...state, items: newItems };
}
return {
...state,
items: [...state.items, { ...action.payload, quantity: 1 }],
};
}
case 'REMOVE_ITEM':
return {
...state,
items: state.items.filter((item) => item.id !== action.payload),
};
case 'UPDATE_QUANTITY': {
const { id, quantity } = action.payload;
if (quantity <= 0) {
return {
...state,
items: state.items.filter((item) => item.id !== id),
};
}
return {
...state,
items: state.items.map((item) =>
item.id === id ? { ...item, quantity } : item
),
};
}
case 'CLEAR_CART':
return { ...state, items: [] };
case 'APPLY_DISCOUNT':
return { ...state, discount: action.payload };
default:
throw new Error(`未知的 action: ${action.type}`);
}
}
const initialState = {
items: [],
discount: 0,
};
// ============================================================
// 商品資料
// ============================================================
const products = [
{ id: 1, name: 'React 教學書', price: 580, emoji: '📘' },
{ id: 2, name: 'JavaScript 教學書', price: 520, emoji: '📗' },
{ id: 3, name: '機械鍵盤', price: 2990, emoji: '⌨️' },
{ id: 4, name: '程式設計滑鼠墊', price: 390, emoji: '🖱️' },
];
// ============================================================
// 購物車元件
// ============================================================
function ShoppingCart() {
const [state, dispatch] = useReducer(cartReducer, initialState);
const subtotal = state.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
const discountAmount = Math.round(subtotal * (state.discount / 100));
const total = subtotal - discountAmount;
const totalItems = state.items.reduce((sum, item) => sum + item.quantity, 0);
return (
<div style={{
maxWidth: '800px',
margin: '20px auto',
fontFamily: 'sans-serif',
display: 'grid',
gridTemplateColumns: '1fr 300px',
gap: '24px',
}}>
{/* 左側:商品列表 */}
<div>
<h2>商品列表</h2>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
{products.map((product) => (
<div key={product.id} style={{
padding: '16px',
border: '1px solid #e2e8f0',
borderRadius: '8px',
textAlign: 'center',
}}>
<div style={{ fontSize: '2.5rem' }}>{product.emoji}</div>
<h3>{product.name}</h3>
<p style={{ color: '#e53e3e', fontWeight: 'bold' }}>
NT$ {product.price}
</p>
<button
onClick={() => dispatch({ type: 'ADD_ITEM', payload: product })}
style={{
padding: '8px 20px',
backgroundColor: '#4299e1',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
}}
>
加入購物車
</button>
</div>
))}
</div>
</div>
{/* 右側:購物車 */}
<div style={{
padding: '20px',
backgroundColor: '#f7fafc',
borderRadius: '12px',
height: 'fit-content',
}}>
<h2>
購物車
{totalItems > 0 && (
<span style={{
marginLeft: '8px',
padding: '2px 8px',
borderRadius: '12px',
backgroundColor: '#e53e3e',
color: 'white',
fontSize: '0.8rem',
}}>
{totalItems}
</span>
)}
</h2>
{state.items.length === 0 ? (
<p style={{ color: '#a0aec0', textAlign: 'center' }}>購物車是空的</p>
) : (
<>
{state.items.map((item) => (
<div key={item.id} style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 0',
borderBottom: '1px solid #e2e8f0',
}}>
<div>
<span>{item.emoji} {item.name}</span>
<p style={{ margin: '2px 0', color: '#718096', fontSize: '0.85rem' }}>
NT$ {item.price} × {item.quantity}
</p>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<button onClick={() => dispatch({
type: 'UPDATE_QUANTITY',
payload: { id: item.id, quantity: item.quantity - 1 },
})}>-</button>
<span style={{ padding: '0 8px' }}>{item.quantity}</span>
<button onClick={() => dispatch({
type: 'UPDATE_QUANTITY',
payload: { id: item.id, quantity: item.quantity + 1 },
})}>+</button>
<button
onClick={() => dispatch({ type: 'REMOVE_ITEM', payload: item.id })}
style={{ color: '#e53e3e', marginLeft: '4px' }}
>
✕
</button>
</div>
</div>
))}
{/* 折扣 */}
<div style={{ marginTop: '12px' }}>
<select
value={state.discount}
onChange={(e) => dispatch({
type: 'APPLY_DISCOUNT',
payload: Number(e.target.value),
})}
style={{ width: '100%', padding: '6px' }}
>
<option value={0}>無折扣</option>
<option value={10}>9 折</option>
<option value={20}>8 折</option>
<option value={30}>7 折</option>
</select>
</div>
{/* 金額 */}
<div style={{ marginTop: '16px', fontSize: '0.9rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>小計:</span>
<span>NT$ {subtotal.toLocaleString()}</span>
</div>
{state.discount > 0 && (
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#48bb78' }}>
<span>折扣 ({state.discount}%):</span>
<span>-NT$ {discountAmount.toLocaleString()}</span>
</div>
)}
<div style={{
display: 'flex',
justifyContent: 'space-between',
fontWeight: 'bold',
fontSize: '1.1rem',
marginTop: '8px',
paddingTop: '8px',
borderTop: '2px solid #2d3748',
}}>
<span>總計:</span>
<span>NT$ {total.toLocaleString()}</span>
</div>
</div>
<button
onClick={() => dispatch({ type: 'CLEAR_CART' })}
style={{
width: '100%',
marginTop: '12px',
padding: '8px',
border: '1px solid #e53e3e',
backgroundColor: 'white',
color: '#e53e3e',
borderRadius: '6px',
cursor: 'pointer',
}}
>
清空購物車
</button>
</>
)}
</div>
</div>
);
}
export default ShoppingCart;
13.5 useReducer + useContext 全域狀態管理
import { createContext, useContext, useReducer } from 'react';
// Reducer
function appReducer(state, action) {
switch (action.type) {
case 'SET_USER':
return { ...state, user: action.payload };
case 'LOGOUT':
return { ...state, user: null };
case 'TOGGLE_THEME':
return { ...state, theme: state.theme === 'light' ? 'dark' : 'light' };
case 'ADD_NOTIFICATION':
return {
...state,
notifications: [...state.notifications, action.payload],
};
case 'DISMISS_NOTIFICATION':
return {
...state,
notifications: state.notifications.filter((n) => n.id !== action.payload),
};
default:
return state;
}
}
const initialState = {
user: null,
theme: 'light',
notifications: [],
};
// Context
const AppContext = createContext();
function AppProvider({ children }) {
const [state, dispatch] = useReducer(appReducer, initialState);
return (
<AppContext.Provider value={{ state, dispatch }}>
{children}
</AppContext.Provider>
);
}
function useApp() {
const context = useContext(AppContext);
if (!context) throw new Error('useApp 必須在 AppProvider 內使用');
return context;
}
// 使用方式
function SomeComponent() {
const { state, dispatch } = useApp();
return (
<div>
<p>主題:{state.theme}</p>
<button onClick={() => dispatch({ type: 'TOGGLE_THEME' })}>
切換主題
</button>
<button onClick={() => dispatch({
type: 'SET_USER',
payload: { name: 'Alice' },
})}>
登入
</button>
</div>
);
}
13.6 練習題
練習 1:建立一個任務管理器
使用 useReducer 實作以下功能: - 新增/刪除/編輯任務 - 切換任務狀態(待辦/進行中/完成) - 過濾和排序任務 - 批次操作(全選/全部完成)
本課重點回顧
useReducer適合複雜狀態邏輯- Reducer 是純函式:
(state, action) => newState - 用
dispatch發送 action 來觸發狀態更新 - useReducer + useContext = 輕量級全域狀態管理
- Reducer 容易單元測試