13.1 什麼是 useReducer?

useReduceruseState 的替代方案,適合管理複雜的狀態邏輯。它借鑑了 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 實作以下功能: - 新增/刪除/編輯任務 - 切換任務狀態(待辦/進行中/完成) - 過濾和排序任務 - 批次操作(全選/全部完成)


本課重點回顧

  1. useReducer 適合複雜狀態邏輯
  2. Reducer 是純函式(state, action) => newState
  3. dispatch 發送 action 來觸發狀態更新
  4. useReducer + useContext = 輕量級全域狀態管理
  5. Reducer 容易單元測試