React useReducer: Complex State Management Patterns (2026)

useReducer is React's built-in alternative to useState for managing complex state logic. Rather than scattered setState calls, you describe all state transitions as explicit actions handled in a single reducer function. This makes state changes predictable, testable and easy to reason about — especially when multiple pieces of state must update together.

useReducer Basics

import { useReducer } from 'react'

// State shape
const initialState = { count: 0, step: 1 }

// Pure reducer function — no side effects
function reducer(state, action) {
  switch (action.type) {
    case 'INCREMENT':
      return { ...state, count: state.count + state.step }
    case 'DECREMENT':
      return { ...state, count: state.count - state.step }
    case 'SET_STEP':
      return { ...state, step: action.payload }
    case 'RESET':
      return initialState
    default:
      throw new Error(`Unknown action: ${action.type}`)
  }
}

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

  return (
    <div>
      <p>Count: {state.count} (step: {state.step})</p>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
      <button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
      <input
        type="number"
        value={state.step}
        onChange={e => dispatch({ type: 'SET_STEP', payload: +e.target.value })}
      />
      <button onClick={() => dispatch({ type: 'RESET' })}>Reset</button>
    </div>
  )
}

useState vs useReducer

Choose useReducer when:

  • Multiple state values update together in response to the same event
  • Next state depends on previous state in complex ways
  • You need to pass dispatch (not state setters) to deeply nested components
  • You want to test state logic in isolation
  • State transitions need to be auditable (logging, debugging)

Stick with useState for simple, independent pieces of state (a toggle, an input value, a loading flag).

TypeScript Patterns

// Discriminated union for actions — exhaustiveness checked by TypeScript
type Action =
  | { type: 'INCREMENT' }
  | { type: 'DECREMENT' }
  | { type: 'SET_STEP'; payload: number }
  | { type: 'RESET' }

interface CounterState {
  count: number
  step: number
}

function reducer(state: CounterState, action: Action): CounterState {
  switch (action.type) {
    case 'INCREMENT':
      return { ...state, count: state.count + state.step }
    case 'DECREMENT':
      return { ...state, count: state.count - state.step }
    case 'SET_STEP':
      return { ...state, step: action.payload }
    case 'RESET':
      return initialState
    // TypeScript error if you forget a case — exhaustive check
  }
}

// Action creator helpers
const actions = {
  increment: (): Action => ({ type: 'INCREMENT' }),
  setStep: (step: number): Action => ({ type: 'SET_STEP', payload: step }),
}

Combining with Context

The classic pattern for lightweight global state: useReducer for logic, Context for distribution:

// store/cart.tsx
import { createContext, useContext, useReducer, ReactNode } from 'react'

interface CartItem { id: string; name: string; qty: number; price: number }
interface CartState { items: CartItem[]; isOpen: boolean }

type CartAction =
  | { type: 'ADD_ITEM'; payload: CartItem }
  | { type: 'REMOVE_ITEM'; payload: string }
  | { type: 'UPDATE_QTY'; payload: { id: string; qty: number } }
  | { type: 'TOGGLE_CART' }
  | { type: 'CLEAR' }

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'ADD_ITEM': {
      const existing = state.items.find(i => i.id === action.payload.id)
      if (existing) {
        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] }
    }
    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 'TOGGLE_CART':
      return { ...state, isOpen: !state.isOpen }
    case 'CLEAR':
      return { items: [], isOpen: false }
    default:
      return state
  }
}

const CartContext = createContext<{
  state: CartState
  dispatch: React.Dispatch<CartAction>
} | null>(null)

export function CartProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(cartReducer, { items: [], isOpen: false })
  return (
    <CartContext.Provider value={{ state, dispatch }}>
      {children}
    </CartContext.Provider>
  )
}

export function useCart() {
  const ctx = useContext(CartContext)
  if (!ctx) throw new Error('useCart must be inside CartProvider')
  return ctx
}

// Usage
function AddToCartButton({ product }) {
  const { dispatch } = useCart()
  return (
    <button onClick={() => dispatch({ type: 'ADD_ITEM', payload: product })}>
      Add to Cart
    </button>
  )
}

Immer Integration

Use Immer to write mutating-style reducers without actually mutating:

import { useReducer } from 'react'
import { produce } from 'immer'

// With Immer — write mutations directly, Immer handles immutability
const cartReducer = produce((draft, action) => {
  switch (action.type) {
    case 'ADD_ITEM': {
      const item = draft.items.find(i => i.id === action.payload.id)
      if (item) {
        item.qty += 1  // Direct mutation — Immer handles the copy
      } else {
        draft.items.push(action.payload)
      }
      break
    }
    case 'REMOVE_ITEM':
      draft.items = draft.items.filter(i => i.id !== action.payload)
      break
  }
})

function useCart() {
  return useReducer(cartReducer, { items: [], isOpen: false })
}

Async Actions

Reducers must be pure and synchronous. Handle async logic outside the reducer:

type FetchState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string }

type FetchAction<T> =
  | { type: 'FETCH_START' }
  | { type: 'FETCH_SUCCESS'; payload: T }
  | { type: 'FETCH_ERROR'; payload: string }

function fetchReducer<T>(state: FetchState<T>, action: FetchAction<T>): FetchState<T> {
  switch (action.type) {
    case 'FETCH_START':   return { status: 'loading' }
    case 'FETCH_SUCCESS': return { status: 'success', data: action.payload }
    case 'FETCH_ERROR':   return { status: 'error', error: action.payload }
    default:              return state
  }
}

function useAsyncData<T>(url: string) {
  const [state, dispatch] = useReducer(fetchReducer<T>, { status: 'idle' })

  useEffect(() => {
    dispatch({ type: 'FETCH_START' })
    fetch(url)
      .then(r => r.json())
      .then(data => dispatch({ type: 'FETCH_SUCCESS', payload: data }))
      .catch(err => dispatch({ type: 'FETCH_ERROR', payload: err.message }))
  }, [url])

  return state
}

Testing Reducers

Pure functions are trivial to test — no mocking required:

import { cartReducer } from './cart'

describe('cartReducer', () => {
  const emptyState = { items: [], isOpen: false }

  test('ADD_ITEM adds a new item', () => {
    const action = { type: 'ADD_ITEM', payload: { id: '1', name: 'Widget', qty: 1, price: 9.99 } }
    const next = cartReducer(emptyState, action)
    expect(next.items).toHaveLength(1)
    expect(next.items[0].name).toBe('Widget')
  })

  test('ADD_ITEM increments qty for existing item', () => {
    const state = { items: [{ id: '1', name: 'Widget', qty: 1, price: 9.99 }], isOpen: false }
    const next = cartReducer(state, { type: 'ADD_ITEM', payload: { id: '1', name: 'Widget', qty: 1, price: 9.99 } })
    expect(next.items[0].qty).toBe(2)
  })

  test('CLEAR empties the cart', () => {
    const state = { items: [{ id: '1', name: 'Widget', qty: 2, price: 9.99 }], isOpen: true }
    const next = cartReducer(state, { type: 'CLEAR' })
    expect(next.items).toHaveLength(0)
    expect(next.isOpen).toBe(false)
  })
})

Advanced Patterns

Lazy initializer — compute expensive initial state only once:

function init(initialCount) {
  // Expensive computation runs only on mount
  return { count: initialCount, history: [] }
}

function Counter({ initialCount = 0 }) {
  // init function called once with initialCount as argument
  const [state, dispatch] = useReducer(reducer, initialCount, init)
}

Middleware pattern — wrap dispatch for logging/analytics:

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

  const loggedDispatch = useCallback((action) => {
    console.log('Dispatching:', action)
    console.log('Previous state:', state)
    dispatch(action)
    // Note: state here is still previous — log next state in reducer
  }, [dispatch, state])

  return [state, loggedDispatch]
}