ReactuseReducer Hook

useReducer Hook

useState is perfect for simple values — a boolean, a string, a number. But as state grows more complex — multiple related fields, many ways to mutate it, transitions that depend on the previous state — you end up writing scattered setState calls that are hard to test and reason about.

useReducer is an alternative to useState that moves all state transitions into a single pure function called a reducer. It is the same pattern at the heart of Redux, but built directly into React with no extra dependencies.

The Signature

JSX
const [state, dispatch] = useReducer(reducer, initialState)
  • reducer — a pure function (state, action) => newState

  • initialState — the value state starts at

  • state — the current state value (like the first element from useState)

  • dispatch — a function you call with an action to trigger a state change

The Reducer Function

A reducer takes the current state and an action object, and returns the next state. It must be a pure function — no side effects, no mutations, just return a new value based on the inputs.

JSX
function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 }
    case 'decrement':
      return { count: state.count - 1 }
    case 'reset':
      return { count: 0 }
    default:
      // Always handle the default to avoid silent bugs
      throw new Error('Unknown action: ' + action.type)
  }
}
A Simple Counter

JSX
import { useReducer } from 'react'

const initialState = { count: 0 }

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 }
    case 'decrement':
      return { count: state.count - 1 }
    case 'reset':
      return { count: 0 }
    default:
      throw new Error('Unknown action: ' + action.type)
  }
}

export function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState)

  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
      <button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
    </div>
  )
}
Note
dispatch is stable — its reference never changes between renders. This makes it safe to pass to child components or include in useEffect dependency arrays without causing extra runs.
Actions with Payload

Actions can carry additional data in a payload field. This lets one action type handle different values:

JSX
// Dispatch an action with a payload
dispatch({ type: 'setCount', payload: 42 })
dispatch({ type: 'addItem', payload: { id: 1, name: 'Apple', price: 1.5 } })

// Handle it in the reducer
function reducer(state, action) {
  switch (action.type) {
    case 'setCount':
      return { ...state, count: action.payload }
    case 'addItem':
      return { ...state, items: [...state.items, action.payload] }
    // ...
  }
}
Shopping Cart Example

Here is a realistic shopping cart that demonstrates why useReducer shines when you have multiple operations on the same state:

JSX
import { useReducer } from 'react'

const initialState = {
  items: [],    // { id, name, price, qty }
  coupon: null,
}

function cartReducer(state, action) {
  switch (action.type) {
    case 'add_item': {
      const exists = state.items.find(i => i.id === action.payload.id)
      if (exists) {
        // Increment quantity if already in cart
        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),
      }

    case 'update_qty':
      return {
        ...state,
        items: state.items.map(i =>
          i.id === action.payload.id ? { ...i, qty: action.payload.qty } : i
        ),
      }

    case 'apply_coupon':
      return { ...state, coupon: action.payload }

    case 'clear_cart':
      return initialState

    default:
      throw new Error('Unknown cart action: ' + action.type)
  }
}

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

  const total = cart.items.reduce((sum, i) => sum + i.price * i.qty, 0)

  return (
    <div>
      <h2>Cart ({cart.items.length} items)</h2>

      {cart.items.map(item => (
        <div key={item.id} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
          <span>{item.name}</span>
          <input
            type="number"
            value={item.qty}
            min={1}
            onChange={e =>
              dispatch({
                type: 'update_qty',
                payload: { id: item.id, qty: Number(e.target.value) },
              })
            }
            style={{ width: 48 }}
          />
          <span>${(item.price * item.qty).toFixed(2)}</span>
          <button onClick={() => dispatch({ type: 'remove_item', payload: item.id })}>
            Remove
          </button>
        </div>
      ))}

      <p>
        <strong>Total: ${total.toFixed(2)}</strong>
      </p>
      <button onClick={() => dispatch({ type: 'clear_cart' })}>Clear Cart</button>
    </div>
  )
}
Lazy Initialization

If computing the initial state is expensive — reading from localStorage, parsing a large structure — pass an init function as the third argument. React calls init(initialArg) once on mount instead of on every render:

JSX
function initCart(savedCart) {
  // Called once — safe to do expensive work here
  try {
    return JSON.parse(savedCart) ?? { items: [], coupon: null }
  } catch {
    return { items: [], coupon: null }
  }
}

// Pass the raw arg as second param; the init fn as third param
const [cart, dispatch] = useReducer(
  cartReducer,
  localStorage.getItem('cart'), // passed to initCart
  initCart
)
TypeScript: Typed Actions

Using a discriminated union for the action type makes the reducer fully type-safe and gives you autocomplete on payload:

TSX
type Item = { id: number; name: string; price: number; qty: number }

type CartState = { items: Item[]; coupon: string | null }

type CartAction =
  | { type: 'add_item'; payload: Omit<Item, 'qty'> }
  | { type: 'remove_item'; payload: number }
  | { type: 'update_qty'; payload: { id: number; qty: number } }
  | { type: 'apply_coupon'; payload: string }
  | { type: 'clear_cart' }

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'add_item':
      // TypeScript knows action.payload is Omit<Item, 'qty'> here
      return { ...state, items: [...state.items, { ...action.payload, qty: 1 }] }
    // ...
    default:
      return state
  }
}
When useReducer Beats useState

Signal

Why reducer helps

State has 3+ related fields

One action updates multiple fields atomically

Many different mutations are possible

Each mutation is a named action — easy to find in a switch

Next state depends on previous state

Reducer always receives the latest state — no closure bugs

State transitions need to be tested

Reducer is a pure function — test it directly without rendering

Team members need to understand what can change

The action union serves as living documentation

Tip
Move the reducer function outside the component. It is a pure function so it does not need to close over any component-level variables. This also makes it trivially testable: import the reducer, call it with different states and actions, and assert on the return value.
Warning
Never mutate state directly inside a reducer. Always return a new object: `return { ...state, field: newValue }`. Mutating the existing object prevents React from detecting the change and re-rendering.