EP.25Web Development Bootcamp

useReducer:
當 useState 不夠用的時候

dispatch / action / reducer 三角關係、購物車實戰 — 從 useState 升級到 useReducer 的正確時機

Joseph Chen 2026 14 min read useReducer · Context API · TypeScript

Section 1:你什麼時候需要 useReducer

很多開發者在學完 useState 之後,遇到複雜狀態就繼續硬撐,直到元件裡塞了 5、6 個 useState, 每個操作函式都要同時協調好幾個 state,才意識到事情不對勁。

先看一個具體的例子:用 useState 管理購物車,看看這有多痛苦。

tsx
function Cart() {
  const [items, setItems] = useState<CartItem[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [discount, setDiscount] = useState(0);
  const [couponCode, setCouponCode] = useState('');

  const addItem = (product) => {
    setItems(prev => {
      const existing = prev.find(i => i.id === product.id);
      if (existing) {
        return prev.map(i => i.id === product.id ? { ...i, qty: i.qty + 1 } : i);
      }
      return [...prev, { ...product, qty: 1 }];
    });
  };

  const removeItem = (productId) => { /* ... */ };
  const updateQty = (productId, qty) => { /* ... */ };
  const applyCoupon = async (code) => {
    setLoading(true);
    setError(null);
    try {
      const discount = await validateCoupon(code);
      setDiscount(discount);
    } catch {
      setError('優惠券無效');
    } finally {
      setLoading(false);
    }
  };
  const clearCart = () => { /* ... */ };
}

這就是 useReducer 要解決的問題

5 個 useState,6 個操作函式,每個函式還要手動協調多個 state。 當邏輯繼續複雜化,這個元件將難以維護、難以測試、難以理解。

什麼時候該從 useState 升級到 useReducer?

用 useState用 useReducer
單一簡單值多個相關的 state
操作邏輯簡單多個操作共用複雜邏輯
state 之間互相獨立state 之間有關聯
1–2 個操作3+ 個操作

Section 2:useReducer 三角關係

useReducer 就像一個「郵差中心」的系統:

元件(Component)

發出「訂單」— 呼叫 dispatch(action),說明你想做什麼操作。

Reducer(郵差中心)

根據「訂單類型」(action.type)決定怎麼改變倉庫,永遠回傳一個新的 state 物件。

State(倉庫)

儲存所有相關的狀態。更新後,React 重新渲染元件,畫面根據新倉庫狀態展示。

畫面(UI)

根據最新的 state 重新渲染,展示給用戶看。用戶操作又觸發新的 dispatch,形成循環。

Action 的型別定義(TypeScript 聯合型別)

在 TypeScript 中,用聯合型別(Union Type)定義所有可能的 action,讓 reducer 裡的 switch 可以做到完整的型別推導。

tsx
// Action 的型別定義(TypeScript 聯合型別)
type CartAction =
  | { type: 'ADD_ITEM'; payload: Product }
  | { type: 'REMOVE_ITEM'; payload: { id: string } }
  | { type: 'UPDATE_QTY'; payload: { id: string; qty: number } }
  | { type: 'APPLY_COUPON'; payload: { code: string; discount: number } }
  | { type: 'CLEAR_CART' }
  | { type: 'SET_LOADING'; payload: boolean }
  | { type: 'SET_ERROR'; payload: string | null };

// State 型別
type CartState = {
  items: CartItem[];
  loading: boolean;
  error: string | null;
  discount: number;
  couponCode: string;
};

const initialState: CartState = {
  items: [],
  loading: false,
  error: null,
  discount: 0,
  couponCode: '',
};

為什麼用聯合型別定義 Action?

當你在 switch 的每個 case 裡存取 action.payload, TypeScript 會根據 action.type 自動縮小型別(Type Narrowing)。 例如在 case 'ADD_ITEM' 裡, TypeScript 知道 payload 是 Product, 不是 { id: string }}。 這讓 reducer 的每個分支都能享有完整的型別安全。

Section 3:Reducer 函式

Reducer 是整個模式的核心。它接受「目前的 state」和「一個 action」,回傳「新的 state」。 所有的更新邏輯都集中在這裡,取代了分散在各個 handler 函式的 setXxx 呼叫。

tsx
function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'ADD_ITEM': {
      const existing = state.items.find(i => i.id === action.payload.id);
      if (existing) {
        return {
          ...state,
          items: state.items.map(i =>
            i.id === action.payload.id ? { ...i, qty: i.qty + 1 } : i
          ),
        };
      }
      return { ...state, items: [...state.items, { ...action.payload, qty: 1 }] };
    }

    case 'REMOVE_ITEM':
      return {
        ...state,
        items: state.items.filter(i => i.id !== action.payload.id),
      };

    case 'UPDATE_QTY':
      return {
        ...state,
        items: state.items
          .map(i =>
            i.id === action.payload.id ? { ...i, qty: action.payload.qty } : i
          )
          .filter(i => i.qty > 0), // qty 到 0 自動移除
      };

    case 'APPLY_COUPON':
      return {
        ...state,
        discount: action.payload.discount,
        couponCode: action.payload.code,
      };

    case 'CLEAR_CART':
      return initialState; // 重置到初始狀態

    case 'SET_LOADING':
      return { ...state, loading: action.payload };

    case 'SET_ERROR':
      return { ...state, error: action.payload };

    default:
      return state;
  }
}

Reducer 是純函式(Pure Function)

1. 相同輸入 = 相同輸出

輸入相同的 state + action,永遠輸出相同的新 state。沒有隨機性、沒有外部依賴。

2. 不能有副作用

不能在 reducer 裡 fetch API、不能 setTimeout、不能操作 DOM。副作用留給 useEffect 或 event handler。

3. 不修改原始 state

永遠回傳新物件(...state)。直接修改原始 state 不會觸發 React re-render。

Section 4:在元件中使用 useReducer

有了 reducer 函式和 action 型別定義,在元件裡呼叫 useReducer 非常簡潔。 所有操作函式都變成單一行的 dispatch 呼叫。

tsx
function Cart() {
  const [state, dispatch] = useReducer(cartReducer, initialState);
  const { items, loading, error, discount, couponCode } = state;

  // 操作函式變得很簡潔 — 每個只需一行
  const addItem = (product: Product) =>
    dispatch({ type: 'ADD_ITEM', payload: product });

  const removeItem = (id: string) =>
    dispatch({ type: 'REMOVE_ITEM', payload: { id } });

  // 非同步操作:在 event handler 裡依序 dispatch 多個 action
  const applyCoupon = async (code: string) => {
    dispatch({ type: 'SET_LOADING', payload: true });
    dispatch({ type: 'SET_ERROR', payload: null });
    try {
      const discount = await validateCoupon(code);
      dispatch({ type: 'APPLY_COUPON', payload: { code, discount } });
    } catch {
      dispatch({ type: 'SET_ERROR', payload: '優惠券無效或已過期' });
    } finally {
      dispatch({ type: 'SET_LOADING', payload: false });
    }
  };

  const totalPrice = useMemo(() =>
    items.reduce((sum, i) => sum + i.price * i.qty, 0) * (1 - discount),
    [items, discount]
  );

  return (
    <div>
      {error && <div className="text-red-500">{error}</div>}
      {loading && <div className="text-gray-400">驗證中...</div>}

      {items.map(item => (
        <CartItem
          key={item.id}
          item={item}
          onRemove={() => removeItem(item.id)}
          onQtyChange={(qty) =>
            dispatch({ type: 'UPDATE_QTY', payload: { id: item.id, qty } })
          }
        />
      ))}

      <CouponInput
        value={couponCode}
        onApply={applyCoupon}
        discount={discount}
      />

      <div className="text-xl font-bold">
        總計:NT$ {totalPrice.toLocaleString()}
      </div>

      <button onClick={() => dispatch({ type: 'CLEAR_CART' })}>
        清空購物車
      </button>
    </div>
  );
}

useReducer 讓元件職責變得清晰

元件(Cart)負責

  • • 呼叫 useReducer 取得 state 和 dispatch
  • • 根據 state 渲染 UI
  • • 在事件裡 dispatch action
  • • 處理非同步邏輯(fetch、try/catch)

Reducer 負責

  • • 根據 action 計算新的 state
  • • 所有「如何更新」的邏輯集中在此
  • • 保持純函式,不做任何副作用
  • • 可以獨立測試,不需要渲染任何 UI

Section 5:useReducer + Context — 全域狀態管理

useReducer 本身只解決「單一元件」內的複雜狀態問題。如果你希望購物車狀態在整個 App 都能存取, 做法是把 useReducer 放進 Context 裡。 這個組合被稱為「輕量 Redux」,在不引入第三方狀態管理庫的情況下, 實現全域可存取的結構化狀態。

tsx
import { createContext, useContext, useReducer } from 'react';

// ── 1. 建立 Context ───────────────────────────────────────────────
const CartContext = createContext<{
  state: CartState;
  dispatch: React.Dispatch<CartAction>;
} | undefined>(undefined);

// ── 2. 建立 Provider 元件 ─────────────────────────────────────────
export function CartProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(cartReducer, initialState);

  return (
    <CartContext.Provider value={{ state, dispatch }}>
      {children}
    </CartContext.Provider>
  );
}

// ── 3. 建立自訂 Hook,封裝 useContext + 錯誤處理 ─────────────────
export function useCart() {
  const context = useContext(CartContext);
  if (!context) throw new Error('useCart must be used within CartProvider');
  return context;
}

// ── 4. 在 layout 或 App 層包裹 Provider ──────────────────────────
// app/layout.tsx
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <CartProvider>
          {children}
        </CartProvider>
      </body>
    </html>
  );
}

// ── 5. 任何深層元件都可以直接存取購物車狀態 ──────────────────────
function ProductCard({ product }: { product: Product }) {
  const { state, dispatch } = useCart();
  const inCart = state.items.some(i => i.id === product.id);

  return (
    <div className="product-card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>NT$ {product.price.toLocaleString()}</p>
      <button
        onClick={() => dispatch({ type: 'ADD_ITEM', payload: product })}
        className={inCart ? 'btn-secondary' : 'btn-primary'}
      >
        {inCart ? '已在購物車' : '加入購物車'}
      </button>
    </div>
  );
}

// 購物車 icon 元件(顯示數量)
function CartIcon() {
  const { state } = useCart();
  const totalQty = state.items.reduce((sum, i) => sum + i.qty, 0);

  return (
    <div className="relative">
      <ShoppingCartIcon size={24} />
      {totalQty > 0 && (
        <span className="absolute -top-2 -right-2 bg-red-500 text-white
          text-xs rounded-full w-5 h-5 flex items-center justify-center">
          {totalQty}
        </span>
      )}
    </div>
  );
}

這個模式解決了什麼?

集中的更新邏輯

所有狀態改變都透過 reducer,一眼就能看到所有可能的更新操作。

任何層級可存取

不需要 prop drilling,深層元件直接 useCart() 即可存取和更新狀態。

無額外依賴

純 React 內建 API,不需要安裝 Redux、Zustand 等外部套件。

Section 6:useReducer vs useState vs Zustand

選擇狀態管理工具沒有「最好」,只有「最適合當下場景」。以下是三者的對比,幫助你做出正確選擇。

useStateuseReducerZustand
學習成本最低低–中
適合場景簡單值複雜相關 state全域狀態
TypeScript容易很好(union type)很好
Devtools
Bundle size內建內建額外 2KB
測試難度簡單最容易(純函式)簡單

useReducer 的隱藏優勢:測試超容易

因為 reducer 是純函式,你可以在完全不渲染任何 UI 的情況下,直接呼叫函式並驗證回傳值。 這是 useReducer 相比 useState 最大的測試優勢。

typescript
// cart.test.ts — 純函式測試,不需要 render 任何元件!
import { cartReducer, initialState } from './cartReducer';

describe('cartReducer', () => {
  test('ADD_ITEM 應該將商品加入購物車', () => {
    const result = cartReducer(initialState, {
      type: 'ADD_ITEM',
      payload: { id: '1', name: 'iPhone 16', price: 30000 },
    });
    expect(result.items).toHaveLength(1);
    expect(result.items[0].qty).toBe(1);
  });

  test('ADD_ITEM 同一商品應該累加數量', () => {
    const stateWithItem = cartReducer(initialState, {
      type: 'ADD_ITEM',
      payload: { id: '1', name: 'iPhone 16', price: 30000 },
    });
    const result = cartReducer(stateWithItem, {
      type: 'ADD_ITEM',
      payload: { id: '1', name: 'iPhone 16', price: 30000 },
    });
    expect(result.items).toHaveLength(1);
    expect(result.items[0].qty).toBe(2);
  });

  test('UPDATE_QTY qty 為 0 應該自動移除商品', () => {
    const stateWithItem = cartReducer(initialState, {
      type: 'ADD_ITEM',
      payload: { id: '1', name: 'iPhone 16', price: 30000 },
    });
    const result = cartReducer(stateWithItem, {
      type: 'UPDATE_QTY',
      payload: { id: '1', qty: 0 },
    });
    expect(result.items).toHaveLength(0);
  });

  test('CLEAR_CART 應該回到初始狀態', () => {
    const stateWithItem = cartReducer(initialState, {
      type: 'ADD_ITEM',
      payload: { id: '1', name: 'iPhone 16', price: 30000 },
    });
    const result = cartReducer(stateWithItem, { type: 'CLEAR_CART' });
    expect(result).toEqual(initialState);
  });
});

// 對比:如果用 useState,要測試 addItem 邏輯,
// 你必須 render <Cart />、找到按鈕、模擬點擊、
// 再斷言 DOM 上的數字 — 複雜度高出好幾倍。

總結:如何選擇?

  • useState— 獨立的簡單值、1–2 個操作。例如:modal 的 isOpen、input 的即時值。
  • useReducer— 多個相關 state、3+ 個操作、需要可測試性。例如:購物車、表單狀態機、遊戲狀態。
  • Zustand— 跨多個路由的全域狀態、需要 devtools、團隊規模較大。例如:用戶認證、主題設定、通知系統。

本篇涵蓋技術

useReducer
useState
Context API
State Management
TypeScript
React