Blog

/

Frontend

Next.js - useReducer vs useState

useState useReducer

Posted at

2024年10月30日

Posted on

Frontend

useReducer 和 useState 都能拿來管理狀態,useState 是最簡單的狀態管理方式,每個狀態獨立,setState 就會立即更新。但當狀態之間有邏輯關係時,useState 會讓代碼變得很亂。

而 useReducer 把相關狀態和轉換邏輯集中在一個 reducer 函數裡。所有狀態變化都必須通過 action,便於追蹤和測試。

特性

useState

useReducer

適用場景

簡單獨立的狀態

複雜相關的狀態

狀態更新

直接呼叫 setState

通過 dispatch action

邏輯集中度

分散在組件各處

集中在 reducer 函數

可測試性

難(邏輯散亂)

易(reducer 是純函數)

使用場景

輸入框、開關、計數器

表單驗證、購物車、訂單流程

useState 的問題

當狀態開始變複雜時,useState 會暴露問題。看這個例子:一個表單有名稱、信箱、密碼三個欄位,還要追蹤驗證錯誤和提交狀態。

'use client'

import { useState } from 'react'

export function RegistrationForm() {
  const [name, setName] = useState('')
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [errors, setErrors] = useState({})
  const [isSubmitting, setIsSubmitting] = useState(false)

  const handleNameChange = (e) => {
    setName(e.target.value)
    setErrors(prev => ({ ...prev, name: '' }))
  }

  const handleEmailChange = (e) => {
    setEmail(e.target.value)
    setErrors(prev => ({ ...prev, email: '' }))
  }

  const handlePasswordChange = (e) => {
    setPassword(e.target.value)
    setErrors(prev => ({ ...prev, password: '' }))
  }

  const handleSubmit = async (e) => {
    e.preventDefault()
    setIsSubmitting(true)

    try {
      const res = await fetch('/api/register', {
        method: 'POST',
        body: JSON.stringify({ name, email, password })
      })

      if (!res.ok) {
        const data = await res.json()
        setErrors(data.errors)
      }
    } catch (error) {
      setErrors({ form: error.message })
    } finally {
      setIsSubmitting(false)
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={name} onChange={handleNameChange} placeholder="Name" />
      {errors.name && <span>{errors.name}</span>}

      <input value={email} onChange={handleEmailChange} placeholder="Email" />
      {errors.email && <span>{errors.email}</span>}

      <input value={password} onChange={handlePasswordChange} placeholder="Password" type="password" />
      {errors.password && <span>{errors.password}</span>}

      <button disabled={isSubmitting}>
        {isSubmitting ? '提交中...' : '註冊'}
      </button>
    </form>
  )
}

狀態邏輯散落各地,修改一個欄位要同時改變欄位值和錯誤狀態,代碼重複。如果再加個「密碼確認」欄位,重複會更多。

useReducer 的優勢

用 useReducer 重寫同樣的表單:

'use client'

import { useReducer } from 'react'

const initialState = {
  name: '',
  email: '',
  password: '',
  errors: {},
  isSubmitting: false
}

function formReducer(state, action) {
  switch (action.type) {
    case 'SET_FIELD':
      return {
        ...state,
        [action.field]: action.value,
        errors: { ...state.errors, [action.field]: '' }
      }

    case 'SET_ERRORS':
      return { ...state, errors: action.errors }

    case 'SET_SUBMITTING':
      return { ...state, isSubmitting: action.value }

    case 'RESET':
      return initialState

    default:
      return state
  }
}

export function RegistrationForm() {
  const [state, dispatch] = useReducer(formReducer, initialState)

  const handleChange = (e) => {
    dispatch({
      type: 'SET_FIELD',
      field: e.target.name,
      value: e.target.value
    })
  }

  const handleSubmit = async (e) => {
    e.preventDefault()
    dispatch({ type: 'SET_SUBMITTING', value: true })

    try {
      const res = await fetch('/api/register', {
        method: 'POST',
        body: JSON.stringify({
          name: state.name,
          email: state.email,
          password: state.password
        })
      })

      if (!res.ok) {
        const data = await res.json()
        dispatch({ type: 'SET_ERRORS', errors: data.errors })
      } else {
        dispatch({ type: 'RESET' })
      }
    } catch (error) {
      dispatch({
        type: 'SET_ERRORS',
        errors: { form: error.message }
      })
    } finally {
      dispatch({ type: 'SET_SUBMITTING', value: false })
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        name="name"
        value={state.name}
        onChange={handleChange}
        placeholder="Name"
      />
      {state.errors.name && <span>{state.errors.name}</span>}

      <input
        name="email"
        value={state.email}
        onChange={handleChange}
        placeholder="Email"
      />
      {state.errors.email && <span>{state.errors.email}</span>}

      <input
        name="password"
        type="password"
        value={state.password}
        onChange={handleChange}
        placeholder="Password"
      />
      {state.errors.password && <span>{state.errors.password}</span>}

      <button disabled={state.isSubmitting}>
        {state.isSubmitting ? '提交中...' : '註冊'}
      </button>
    </form>
  )
}

現在所有狀態轉換邏輯都在 formReducer 中。要加新欄位?只需在 reducer 中加一個 case。要改變狀態轉換邏輯?都在 reducer 裡。

實例 1:電商購物車

購物車涉及多個操作:添加商品、更新數量、刪除商品、清空購物車。用 useState 會很亂,用 useReducer 會易讀很多。

'use client'

import { useReducer } from 'react'

const initialState = {
  items: [],
  totalPrice: 0,
  itemCount: 0
}

function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM': {
      const existingItem = state.items.find(item => item.id === action.payload.id)

      if (existingItem) {
        const updatedItems = state.items.map(item =>
          item.id === action.payload.id
            ? { ...item, quantity: item.quantity + 1 }
            : item
        )
        return {
          items: updatedItems,
          itemCount: state.itemCount + 1,
          totalPrice: updatedItems.reduce((sum, item) => sum + item.price * item.quantity, 0)
        }
      }

      const newItems = [...state.items, { ...action.payload, quantity: 1 }]
      return {
        items: newItems,
        itemCount: state.itemCount + 1,
        totalPrice: newItems.reduce((sum, item) => sum + item.price * item.quantity, 0)
      }
    }

    case 'UPDATE_QUANTITY': {
      const updatedItems = state.items.map(item =>
        item.id === action.payload.id
          ? { ...item, quantity: action.payload.quantity }
          : item
      ).filter(item => item.quantity > 0)

      return {
        items: updatedItems,
        itemCount: updatedItems.reduce((sum, item) => sum + item.quantity, 0),
        totalPrice: updatedItems.reduce((sum, item) => sum + item.price * item.quantity, 0)
      }
    }

    case 'REMOVE_ITEM': {
      const updatedItems = state.items.filter(item => item.id !== action.payload)
      return {
        items: updatedItems,
        itemCount: updatedItems.reduce((sum, item) => sum + item.quantity, 0),
        totalPrice: updatedItems.reduce((sum, item) => sum + item.price * item.quantity, 0)
      }
    }

    case 'CLEAR':
      return initialState

    default:
      return state
  }
}

export function ShoppingCart({ products }) {
  const [cart, dispatch] = useReducer(cartReducer, initialState)

  const handleAddToCart = (product) => {
    dispatch({ type: 'ADD_ITEM', payload: product })
  }

  const handleUpdateQuantity = (productId, quantity) => {
    dispatch({ type: 'UPDATE_QUANTITY', payload: { id: productId, quantity } })
  }

  const handleRemove = (productId) => {
    dispatch({ type: 'REMOVE_ITEM', payload: productId })
  }

  const handleClear = () => {
    dispatch({ type: 'CLEAR' })
  }

  return (
    <div>
      <h2>購物車</h2>
      <p>商品數: {cart.itemCount}</p>
      <p>總金額: ${cart.totalPrice.toFixed(2)}</p>

      <ul>
        {cart.items.map(item => (
          <li key={item.id}>
            {item.name} - ${item.price} x {item.quantity}
            <input
              type="number"
              min="1"
              value={item.quantity}
              onChange={(e) => handleUpdateQuantity(item.id, parseInt(e.target.value))}
            />
            <button onClick={() => handleRemove(item.id)}>刪除</button>
          </li>
        ))}
      </ul>

      <button onClick={handleClear}>清空購物車</button>

      <div>
        <h3>可購買商品</h3>
        {products.map(product => (
          <button key={product.id} onClick={() => handleAddToCart(product)}>
            加入 {product.name}
          </button>
        ))}
      </div>
    </div>
  )
}

所有購物車邏輯(添加、更新、刪除、清空)都集中在 cartReducer 中。每個 action 清晰地描述發生了什麼。測試時可以直接測試 reducer 而不用跑整個組件。

實例 2:POS 訂單流程

訂單從「待確認」到「已確認」到「已付款」到「已發貨」,每個狀態轉換有特定條件。用 useState 會有多個 isConfirmed、isPaid、isShipped 狀態,邏輯會很難維護。

'use client'

import { useReducer } from 'react'

const initialState = {
  status: 'pending',
  order: null,
  error: null,
  loading: false
}

function orderReducer(state, action) {
  switch (action.type) {
    case 'SET_ORDER':
      return { ...state, order: action.payload, loading: false }

    case 'CONFIRM_ORDER':
      if (state.status !== 'pending') {
        return { ...state, error: '只能確認待處理訂單' }
      }
      return { ...state, status: 'confirmed', error: null }

    case 'PROCESS_PAYMENT':
      if (state.status !== 'confirmed') {
        return { ...state, error: '訂單未確認,無法付款' }
      }
      return { ...state, status: 'paid', error: null }

    case 'SHIP_ORDER':
      if (state.status !== 'paid') {
        return { ...state, error: '訂單未付款,無法發貨' }
      }
      return { ...state, status: 'shipped', error: null }

    case 'CANCEL_ORDER':
      if (state.status === 'shipped' || state.status === 'completed') {
        return { ...state, error: '已發貨或完成的訂單無法取消' }
      }
      return { ...state, status: 'cancelled', error: null }

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

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

    default:
      return state
  }
}

export function OrderManagement({ orderId }) {
  const [orderState, dispatch] = useReducer(orderReducer, initialState)

  const handleConfirm = async () => {
    dispatch({ type: 'CONFIRM_ORDER' })
  }

  const handlePayment = async () => {
    dispatch({ type: 'SET_LOADING', value: true })
    try {
      // 調用付款 API
      dispatch({ type: 'PROCESS_PAYMENT' })
    } catch (error) {
      dispatch({ type: 'SET_ERROR', payload: error.message })
    } finally {
      dispatch({ type: 'SET_LOADING', value: false })
    }
  }

  const handleShip = async () => {
    dispatch({ type: 'SET_LOADING', value: true })
    try {
      // 調用發貨 API
      dispatch({ type: 'SHIP_ORDER' })
    } catch (error) {
      dispatch({ type: 'SET_ERROR', payload: error.message })
    } finally {
      dispatch({ type: 'SET_LOADING', value: false })
    }
  }

  const handleCancel = () => {
    dispatch({ type: 'CANCEL_ORDER' })
  }

  return (
    <div>
      <h2>訂單 #{orderId}</h2>
      <p>狀態: {orderState.status}</p>

      {orderState.error && <p style={{ color: 'red' }}>{orderState.error}</p>}

      <button onClick={handleConfirm} disabled={orderState.status !== 'pending'}>
        確認訂單
      </button>
      <button onClick={handlePayment} disabled={orderState.status !== 'confirmed' || orderState.loading}>
        {orderState.loading ? '處理中...' : '付款'}
      </button>
      <button onClick={handleShip} disabled={orderState.status !== 'paid' || orderState.loading}>
        {orderState.loading ? '處理中...' : '發貨'}
      </button>
      <button onClick={handleCancel} disabled={orderState.status === 'shipped' || orderState.status === 'completed'}>
        取消訂單
      </button>
    </div>
  )
}

整個訂單流程的邏輯都在 orderReducer 中,每個轉換有很清處的條件檢查。

常見誤區

誤區 1:用 useReducer 管理所有狀態

// 不好
function componentReducer(state, action) {
  switch (action.type) {
    case 'SET_DARK_MODE':
      return { ...state, darkMode: action.value }
    case 'SET_HOVER':
      return { ...state, isHovering: action.value }
    case 'INCREMENT_COUNTER':
      return { ...state, counter: state.counter + 1 }
  }
}

一個簡單的開關、懸停狀態、計數器不需要 useReducer,這樣反而增加複雜度。useReducer 應該用於有邏輯關係的相關狀態。

誤區 2:reducer 做太多事

// 不好
function formReducer(state, action) {
  switch (action.type) {
    case 'SUBMIT':
      const res = await fetch('/api/submit', { body: JSON.stringify(state) })
      // 在 reducer 中做 fetch 和其他副作用
      return { ...state, data: res.json() }
  }
}

Reducer 應該是純函數。所有副作用(fetch、logging、API 調用)都應該在組件或 useEffect 中做,Reducer 只負責狀態轉換。

誤區 3:沒有明確的 action 類型

// 不好
dispatch({ field: 'name', value: 'John' })
dispatch({ amount: 100 })

// 好
dispatch({ type: 'SET_NAME', value: 'John' })
dispatch({ type: 'ADD_PRICE', amount: 100 })

Action 應該有清晰的 type。