React: Complex State: useReducer
Last updated: 2026-10-26
1. What You'll Learn
- useReducer 's basic pattern(state + action → newState)
- Design Principles for Action Types
- Comparison with useState (When to Use Which)
- useReducer + Context combo (lightweight Redux)
2. A Story About Shopping Cart State Management
(1) Pain Point: Complex Interactions Between Multiple useState Instances
Alice is building a shopping cart component that needs to handle multiple interactions:
JSX
function ShoppingCart() {
const [items, setItems] = useState([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState(null)
const [coupon, setCoupon] = useState(null)
// Add Item
function addItem(product) {
const existing = items.find(i => i.id === product.id)
if (existing) {
// ❌ Several setState The call order depends on the current value
setItems(prev => prev.map(i =>
i.id === product.id ? { ...i, qty: i.qty + 1 } : i
))
} else {
setItems(prev => [...prev, { ...product, qty: 1 }])
}
}
// Apply a Coupon
function applyCoupon(code) {
setIsLoading(true)
fetch('/api/coupon/' + code)
.then(res => res.json())
.then(data => {
if (data.valid) {
setCoupon(data)
setError(null)
} else {
setError('Coupon is invalid')
}
})
.catch(err => setError(err.message))
.finally(() => setIsLoading(false))
}
// Question:items、isLoading、error、coupon The four states are interrelated
// One operation (e.g. applyCoupon) triggers 3 setState calls
// It's easy to miss an update to a particular state,Causes inconsistencies
}
(2) The useReducer Solution
JSX
// 1. Define the Initial State (seeded with 1 product so the cart is visible)
const initialState = {
items: [
{ id: 1, name: 'React 101', qty: 1, price: 59 }
],
isLoading: false,
error: null,
coupon: null,
total: 59
}
// 2. Definition reducer(Pure Functions:Receive Previous State + action → Return to the new state)
function cartReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM': {
const existing = state.items.find(i => i.id === action.product.id)
const newItems = existing
? state.items.map(i =>
i.id === action.product.id ? { ...i, qty: i.qty + 1 } : i
)
: [...state.items, { ...action.product, qty: 1 }]
return { ...state, items: newItems, error: null }
}
case 'REMOVE_ITEM':
return {
...state,
items: state.items.filter(i => i.id !== action.id)
}
case 'UPDATE_QTY':
return {
...state,
items: state.items.map(i =>
i.id === action.id ? { ...i, qty: Math.max(1, action.qty) } : i
)
}
case 'APPLY_COUPON_REQUEST':
return { ...state, isLoading: true, error: null }
case 'APPLY_COUPON_SUCCESS':
return { ...state, isLoading: false, coupon: action.coupon }
case 'APPLY_COUPON_FAILURE':
return { ...state, isLoading: false, error: action.error }
case 'CLEAR_ERROR':
return { ...state, error: null }
case 'RESET_CART':
return initialState
default:
return state
}
}
// 3. Using in a component
function ShoppingCart() {
const [state, dispatch] = useReducer(cartReducer, initialState)
const [code, setCode] = useState('')
function handleAdd(product) {
dispatch({ type: 'ADD_ITEM', product })
}
// Simulated async request for the demo (swap in a real fetch in a real app)
function handleApplyCoupon() {
dispatch({ type: 'APPLY_COUPON_REQUEST' })
setTimeout(() => {
if (code.trim().toUpperCase() === 'SAVE10') {
dispatch({ type: 'APPLY_COUPON_SUCCESS', coupon: { code: code.trim().toUpperCase() } })
} else {
dispatch({ type: 'APPLY_COUPON_FAILURE', error: 'Coupon is invalid (try SAVE10)' })
}
}, 400)
}
return (
<div>
<p>Items in cart:{state.items.reduce((n, i) => n + i.qty, 0)}</p>
{state.items.map(item => (
<div key={item.id} style={{ marginBottom: 6 }}>
{item.name} × {item.qty}
<button onClick={() => dispatch({ type: 'UPDATE_QTY', id: item.id, qty: item.qty + 1 })}>+</button>
<button onClick={() => dispatch({ type: 'REMOVE_ITEM', id: item.id })}>Delete</button>
</div>
))}
<button onClick={() => handleAdd({ id: Date.now(), name: 'React in Action', qty: 1, price: 79 })}>
➕ Add React in Action
</button>
<div style={{ marginTop: 12 }}>
<input
value={code}
onChange={e => setCode(e.target.value)}
placeholder="Enter coupon code"
style={{ padding: 6 }}
/>
<button onClick={handleApplyCoupon}>Apply coupon</button>
</div>
{state.isLoading && <p>Processing......</p>}
{state.error && <p style={{ color: 'red' }}>{state.error}</p>}
{state.coupon && <p>Coupon has been applied:{state.coupon.code}</p>}
</div>
)
}
Benefits: All state changes are centralized in a single reducer function—predictable, testable, and easy to debug. Each action is a record of "what happened."
3. The Core Pattern of useReducer
| Concept | Description | Example |
|---|---|---|
reducer |
Pure function,(state, action) → newState | function cartReducer(state, action) |
action |
An object describing "what happened" | { type: 'ADD_ITEM', payload: product } |
dispatch |
Function to send an action | dispatch({ type: 'INCREMENT' }) |
initialState |
Initial State Object | { items: [], total: 0 } |
init Function |
Lazy Initialization (Optional 3rd Parameter) | useReducer(reducer, initialArg, init) |
graph LR
A[Components] -->|dispatch(action)| B[reducer]
B -->|(state, action) => newState| C[New Status]
C -->|Update| A
D[User Actions] -->|Trigger| A
style B fill:#1890ff,color:#fff
style C fill:#52c41a,color:#fff
(1) Basic Syntax
JSX
const [state, dispatch] = useReducer(reducer, initialState)
// reducer It is a pure function:
function reducer(state, action) {
switch (action.type) {
case 'INCREMENT':
return { ...state, count: state.count + 1 }
case 'SET_NAME':
return { ...state, name: action.payload }
default:
return state
}
}
Output:
TEXT
Side-by-side comparison: HTML syntax vs JSX syntax showing equivalent markup
(2) Action Design Principles
| Principle | Description | Bad Example | Good Example |
|---|---|---|---|
| Use the past tense for type names | Describes "what has already happened" | ADD |
ITEM_ADDED |
| Carry the Minimum Amount of Data | Carry only the necessary data; calculate the rest in the reducer | dispatch({ type: 'SET_TOTAL', total: 42 }) |
dispatch({ type: 'ITEM_ADDED', item }), where total is calculated in the reducer |
| Do not handle side effects | Reducers must be pure functions (do not call APIs or read/write localStorage) | case 'LOGIN': fetch(...) |
Call APIs in useEffect or event handlers, then dispatch after completion |
4. useReducer vs useState
| Dimension | useState | useReducer |
|---|---|---|
| Number of states | 3 or fewer | More than 3, or states are interrelated |
| Update Logic | Simple (toggle, +1) | Complex (multiple sub-values that influence each other) |
| Readability | Good (easy to understand at a glance) | Good (centralized management) |
| Testability | Components that require rendering must be tested | Reducers are pure functions and can be tested directly |
| Suitable Scale | Small components, standalone states | Medium to large components, complex forms, shopping carts |
▶ Example: Comparison of Different Implementations of the Same Functionality
▶ Try it YourselfOutput:
TEXT
Count: 0 | Last Action: none | Operations: 0. +1→INCREMENT, -1→DECREMENT, Reset→initial, Set 100. History tracks all actions.
5. useReducer + Context: Lightweight Global State
When multiple components need to share the same reducer state, use Context to pass the dispatch:
▶ Expand runnable example (86 lines)
6. Complete Example: Multi-Step Form (Wizard)
▶ Expand runnable example (194 lines)
Output:
TEXT
Displays: "Reset". State: count (setter: setCount). Buttons: setCount(c => c + 1)}>+1, setCount(0)}>Reset, dispatch({ type: 'INCREMENT' })}>+1, dispatch({ type: 'DECREMENT' })}>-1. useReducer manages complex state
Expected Output: A 3-step registration form (Account → Profile → Preferences), with validation at each step, the ability to move forward and backward, and final submission followed by the display of the submission result.
▶ Example 2: Asynchronous Data Loading and useReducer
▶ Expand runnable example (117 lines)
Output:
TEXT
Click User 1/2/3 → ⏳ Loading... → 👤 card (User 1, user1@example.com, Administrator, 2026-01-15). "Trigger error" → ❌ "Invalid User ID"
Output:
TEXT
Undo/Redo editor: type text → Save. Undo reverts, Redo restores. Shows past/future state stacks
▶ Example 3: Implementing Undo/Redo Functionality with useReducer
▶ Try it Yourself
Output:
TEXT
Type → Save → "Current: hello". Undo → reverts to previous. Redo → restores. Shows: Past: [v1, v2] | Future: [v4]
Output:
TEXT
Registration form via useReducer: username, email, password, role, agree checkbox. Submit validates fields. Reset clears all
▶ Example 4: Using useReducer to Manage Form State
▶ Try it Yourself
Output:
TEXT
Registration form (Username, Email, Password, Agree checkbox) via useReducer. Invalid submit → red errors. Reset → clears all fields.
Output:
TEXT
Shopping cart context: product list with "Add" buttons. Cart shows items + total. "Clear" empties cart
▶ Example 5: useReducer + Context for a Global Shopping Cart
▶ Expand runnable example (62 lines)
Output:
TEXT
Products (Keyboard $79, Mouse $49, Monitor $399) with "Add". Cart: items × qty + Total. "Clear" empties cart. Global state via Context.
❓ FAQ
Q Why must a reducer be a pure function?
A A pure function means: the same input → the same output; it does not modify external variables; and it has no side effects (does not call APIs or read/write files). React relies on the predictability of reducers for performance optimization (skipping unnecessary renders) and debugging (time travel). Calling APIs inside a reducer can lead to unpredictable state changes, which undermines the debugging experience.
Q When should you switch from
useState to useReducer?A When any of the following three situations occurs: ① A single action needs to update three or more
useState instances (e.g., clicking a button simultaneously updates isLoading, error, and data); ② The state logic is so complex that it’s scattered across multiple event handler functions, making it difficult to track; ③ The component has multiple state sub-values that depend on each other. When to switch: When the useState implementation causes your component to exceed 100 lines of code.Q Can I include side effects (such as fetch requests) in a reducer?
A Absolutely not. A reducer must be a pure function—it must always return the same output for the same input and must not have any side effects, such as API calls, DOM operations, or random numbers. Side effects should be placed in
useEffect or event handler functions. Writing side effects in a reducer leads to: unpredictable state, difficult debugging, and broken time travel (DevTools replay).📖 Summary
useReducer(reducer, initialState)Return[state, dispatch]- A reducer is a pure function:
(state, action) => newState - action contains
type(describing what happened) and an optionalpayload(data) useStateis suitable for simple, independent state, whileuseReduceris suitable for complex, interdependent stateuseReducer + ContextCan be used as an alternative to Redux in small projects
📝 Exercises
- Basic Exercise (Difficulty ⭐): Create a
Countercomponent and useuseReducerto implement four operations: +1, -1, +10, and reset. Display the current value and the operation history (the last 5 operations) on the page. - Advanced Exercise (Difficulty ⭐⭐): Create a
TodoAppcomponent and useuseReducerandContextto implement five functions: add, mark as completed, edit, delete, and clear completed items. - Challenge (Difficulty: ⭐⭐⭐): Create a
UndoRedoListcomponent and useuseReducerto implement "Undo/Redo" functionality. Each operation should save a history snapshot and support Ctrl+Z for Undo and Ctrl+Shift+Z for Redo.