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。


