React: Complex State: useReducer

Last updated: 2026-10-26

1. What You'll Learn



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
}
▶ Try it Yourself

(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>
  )
}
▶ Try it Yourself

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)
100%
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
  }
}
▶ Try it Yourself

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 Yourself

Output:

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)
▶ Try it Yourself

6. Complete Example: Multi-Step Form (Wizard)

▶ Expand runnable example (194 lines)
▶ Try it Yourself

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)
▶ Try it Yourself

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)
▶ Try it Yourself

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


📝 Exercises

  1. Basic Exercise (Difficulty ⭐): Create a Counter component and use useReducer to implement four operations: +1, -1, +10, and reset. Display the current value and the operation history (the last 5 operations) on the page.
  2. Advanced Exercise (Difficulty ⭐⭐): Create a TodoApp component and use useReducer and Context to implement five functions: add, mark as completed, edit, delete, and clear completed items.
  3. Challenge (Difficulty: ⭐⭐⭐): Create a UndoRedoList component and use useReducer to implement "Undo/Redo" functionality. Each operation should save a history snapshot and support Ctrl+Z for Undo and Ctrl+Shift+Z for Redo.
Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏