Mastering Complex State in React with useReducer

A practical guide to complex React state with useReducer: modeling, actions, async flows, context patterns, performance, and testing.

ASOasis
9 min read
Mastering Complex State in React with useReducer

Image used for representation purposes only.

Overview

React’s useReducer hook shines when state is complex, transitions are non-trivial, or many events must be handled consistently. It centralizes how state changes, makes updates predictable, and pairs well with Context for cross-tree sharing. In this guide you’ll learn when to choose useReducer, how to model complex domains, structure actions, handle async work without breaking purity, optimize rendering, and test with confidence.

When to prefer useReducer over useState

Reach for useReducer when:

  • Multiple state variables must change together in well-defined steps (wizard flows, carts, editors).
  • The next state depends on the previous state in nuanced ways.
  • You need a loggable, testable description of state transitions.
  • You plan to share state via Context and want a stable dispatch reference.

Keep useState for simple, isolated values or when you want minimal ceremony.

Anatomy of a reducer

A reducer is a pure function: given (state, action) it returns the next state. It must be synchronous and have no side effects.

import { useReducer } from 'react'

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

type State = {
  entities: Record<string, Item>
  ids: string[]
  cart: Record<string, number> // itemId -> qty
  loading: boolean
  error?: string
}

const initialState: State = {
  entities: {},
  ids: [],
  cart: {},
  loading: false,
}

type Action =
  | { type: 'LOAD_REQUEST' }
  | { type: 'LOAD_SUCCESS'; payload: Item[] }
  | { type: 'LOAD_FAILURE'; payload: string }
  | { type: 'ADD_TO_CART'; payload: { id: string; qty?: number } }
  | { type: 'REMOVE_FROM_CART'; payload: { id: string } }
  | { type: 'CLEAR_CART' }

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'LOAD_REQUEST':
      return { ...state, loading: true, error: undefined }
    case 'LOAD_SUCCESS': {
      const entities = Object.fromEntries(action.payload.map(i => [i.id, i]))
      const ids = action.payload.map(i => i.id)
      return { ...state, entities, ids, loading: false }
    }
    case 'LOAD_FAILURE':
      return { ...state, loading: false, error: action.payload }
    case 'ADD_TO_CART': {
      const { id, qty = 1 } = action.payload
      const nextQty = (state.cart[id] ?? 0) + qty
      return { ...state, cart: { ...state.cart, [id]: nextQty } }
    }
    case 'REMOVE_FROM_CART': {
      const { [action.payload.id]: _, ...rest } = state.cart
      return { ...state, cart: rest }
    }
    case 'CLEAR_CART':
      return { ...state, cart: {} }
    default:
      return state
  }
}

export function useShop() {
  const [state, dispatch] = useReducer(reducer, initialState)
  return { state, dispatch }
}

Key properties:

  • Pure: only derives next state from inputs; no fetches, timers, or logging here.
  • Exhaustive: action.type drives all transitions in one place.
  • Serializable: makes debugging and persistence easier.

Designing state for complexity

Model first; code second.

  • Normalize collections. Use entities (by id map) + ids array. This avoids heavy nested updates and simplifies lookups.
  • Prefer explicit booleans for async flags (loading, saving). Avoid deriving them from other values during mutation because it hides intent.
  • Keep derived data out of state (totals, counts). Derive via selectors/memoization.
  • Segment concerns. If parts of state evolve independently, consider multiple reducers combined at the component or hook level.

Selectors for derived data

import { useMemo } from 'react'

function useCartSelectors(state: State) {
  const total = useMemo(() => {
    return Object.entries(state.cart).reduce((sum, [id, qty]) => {
      const item = state.entities[id]
      return sum + (item ? item.price * qty : 0)
    }, 0)
  }, [state.cart, state.entities])

  const itemsInCart = useMemo(() => Object.keys(state.cart).length, [state.cart])

  return { total, itemsInCart }
}

Initializing and rehydrating state

useReducer supports lazy initialization to compute initial state once, useful for localStorage/session rehydration.

function init(persisted: Partial<State> | null): State {
  return {
    ...initialState,
    ...(persisted ?? {}),
    loading: false,
    error: undefined,
  }
}

const persisted = JSON.parse(localStorage.getItem('shop') || 'null') as Partial<State> | null
const [state, dispatch] = useReducer(reducer, persisted, init)

// persist on change
useEffect(() => {
  localStorage.setItem('shop', JSON.stringify(state))
}, [state])

Tip: If you need to reset all state (e.g., on logout), change a key on the reducer-hosting component to force remount, or add a RESET action that returns initialState.

Handling async without breaking purity

Reducers must stay pure. Trigger side effects in:

  • Event handlers that call dispatch before/after async work.
  • useEffect that reacts to state or command flags.
  • A custom hook that wraps the reducer and exposes bound async actions.

Thunk-like pattern in a custom hook

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

  const loadItems = useCallback(async () => {
    dispatch({ type: 'LOAD_REQUEST' })
    try {
      const res = await fetch('/api/items')
      if (!res.ok) throw new Error('Failed to load')
      const data: Item[] = await res.json()
      dispatch({ type: 'LOAD_SUCCESS', payload: data })
    } catch (e) {
      dispatch({ type: 'LOAD_FAILURE', payload: (e as Error).message })
    }
  }, [])

  const addToCart = useCallback((id: string, qty = 1) => {
    dispatch({ type: 'ADD_TO_CART', payload: { id, qty } })
  }, [])

  return { state, loadItems, addToCart, dispatch }
}

This pattern keeps effects near actions that cause them, while the reducer stays deterministic.

Effect-driven commands

Another pattern is to store an intent in state (e.g., pendingItemId) and let a useEffect respond. Clear the intent after running.

// inside component using the reducer
useEffect(() => {
  if (!state.loading && state.ids.length === 0) {
    // kick off a first load only once
    dispatch({ type: 'LOAD_REQUEST' })
    ;(async () => {
      try {
        const r = await fetch('/api/items')
        const d: Item[] = await r.json()
        dispatch({ type: 'LOAD_SUCCESS', payload: d })
      } catch (e) {
        dispatch({ type: 'LOAD_FAILURE', payload: (e as Error).message })
      }
    })()
  }
}, [state.loading, state.ids.length])

Action design: clarity beats cleverness

  • Use discriminated unions (type field) with explicit payloads.
  • Keep action creators thin; avoid hiding data massaging inside them.
  • Favor specific actions over generic SET_VALUE unless you truly need a form-style mapper.

TypeScript example

type Add = { type: 'ADD_TO_CART'; payload: { id: string; qty?: number } }
type Remove = { type: 'REMOVE_FROM_CART'; payload: { id: string } }

type Action = Add | Remove // …extend as needed

function addToCart(id: string, qty = 1): Add {
  return { type: 'ADD_TO_CART', payload: { id, qty } }
}

This yields strong exhaustiveness checking in switch statements and safer refactors.

Splitting reducers without over-abstracting

If parts of state evolve independently, create feature reducers and compose them in the hosting hook or component.

function cartReducer(cart: State['cart'], action: Action): State['cart'] {
  switch (action.type) {
    case 'ADD_TO_CART':
      return { ...cart, [action.payload.id]: (cart[action.payload.id] ?? 0) + (action.payload.qty ?? 1) }
    case 'REMOVE_FROM_CART': {
      const { [action.payload.id]: _, ...rest } = cart
      return rest
    }
    case 'CLEAR_CART':
      return {}
    default:
      return cart
  }
}

function rootReducer(state: State, action: Action): State {
  switch (action.type) {
    case 'ADD_TO_CART':
    case 'REMOVE_FROM_CART':
    case 'CLEAR_CART':
      return { ...state, cart: cartReducer(state.cart, action) }
    default:
      return reducer(state, action) // fall back to main reducer
  }
}

Composition keeps files small and mental models crisp.

Sharing reducer state with Context

Putting state into Context is natural with useReducer. Split state and dispatch into separate contexts to minimize re-renders.

const StateContext = React.createContext<State | undefined>(undefined)
const DispatchContext = React.createContext<React.Dispatch<Action> | undefined>(undefined)

export function ShopProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(reducer, initialState)
  return (
    <DispatchContext.Provider value={dispatch}>
      <StateContext.Provider value={state}>{children}</StateContext.Provider>
    </DispatchContext.Provider>
  )
}

export function useShopState() {
  const ctx = React.useContext(StateContext)
  if (!ctx) throw new Error('useShopState must be used within ShopProvider')
  return ctx
}

export function useShopDispatch() {
  const ctx = React.useContext(DispatchContext)
  if (!ctx) throw new Error('useShopDispatch must be used within ShopProvider')
  return ctx
}

Notes:

  • dispatch identity is stable across renders, so consumers of DispatchContext won’t re-render when state changes.
  • Components that only need dispatch won’t re-render on state updates; state consumers still will. Combine with React.memo and selectors to reduce work.

Performance checklist

  • Normalize and slice state logically.
  • Use memoized selectors for derived values.
  • Split Context for state and dispatch.
  • Wrap heavy consumers in React.memo and pass minimal props.
  • Avoid creating new objects in context values except state itself.
  • Batch related actions when possible to reduce renders.

Testing reducers is straightforward

Reducers are pure, so write fast, isolated tests.

// pseudo with Vitest/Jest
import { describe, it, expect } from 'vitest'

describe('reducer', () => {
  it('adds items to cart', () => {
    const s1 = reducer(initialState, { type: 'ADD_TO_CART', payload: { id: 'a', qty: 2 } })
    expect(s1.cart['a']).toBe(2)

    const s2 = reducer(s1, { type: 'ADD_TO_CART', payload: { id: 'a', qty: 1 } })
    expect(s2.cart['a']).toBe(3)
  })

  it('handles load success', () => {
    const items = [ { id: 'a', name: 'A', price: 1 } ]
    const s = reducer(initialState, { type: 'LOAD_SUCCESS', payload: items })
    expect(s.entities['a']?.name).toBe('A')
    expect(s.ids).toEqual(['a'])
  })
})

Debugging: add a lightweight logger

Wrap dispatch to log actions and diffs during development. Keep it out of production builds.

function useLoggedReducer<R extends React.Reducer<any, any>>(
  reducer: R,
  initialArg: React.ReducerState<R>,
  initializer?: (arg: React.ReducerState<R>) => React.ReducerState<R>
) {
  const [state, baseDispatch] = useReducer(reducer, initialArg as any, initializer as any)
  const dispatch = useCallback((action: React.ReducerAction<R>) => {
    if (process.env.NODE_ENV !== 'production') {
      // eslint-disable-next-line no-console
      console.log('%caction', 'color: #0bf', action)
    }
    baseDispatch(action)
  }, [])
  return [state, dispatch] as const
}

For deeper tooling, you can instrument dispatch to talk to Redux DevTools, but a simple console tracer often suffices.

Patterns for forms and wizards

Complex forms benefit from reducers when steps depend on prior answers or you need robust undo/redo.

  • Model each step as an action that validates and advances.
  • Keep field-level state in a local reducer; persist only when submitting.
  • Consider a history stack if you need undo.
type Step = 'account' | 'shipping' | 'review'

type FormState = {
  step: Step
  data: {
    email?: string
    address?: string
  }
  errors: Record<string, string>
}

type FormAction =
  | { type: 'NEXT'; payload?: Partial<FormState['data']> }
  | { type: 'BACK' }
  | { type: 'SET_ERROR'; payload: { field: string; message: string } }

function formReducer(s: FormState, a: FormAction): FormState {
  switch (a.type) {
    case 'NEXT': {
      const stepOrder: Step[] = ['account', 'shipping', 'review']
      const nextStep = stepOrder[Math.min(stepOrder.indexOf(s.step) + 1, stepOrder.length - 1)]
      return { ...s, step: nextStep, data: { ...s.data, ...a.payload } }
    }
    case 'BACK': {
      const stepOrder: Step[] = ['account', 'shipping', 'review']
      const prevStep = stepOrder[Math.max(stepOrder.indexOf(s.step) - 1, 0)]
      return { ...s, step: prevStep }
    }
    case 'SET_ERROR': {
      const { field, message } = a.payload
      return { ...s, errors: { ...s.errors, [field]: message } }
    }
    default:
      return s
  }
}

Common pitfalls and how to avoid them

  • Side effects in reducers. Move them into event handlers, effects, or custom hooks.
  • Overusing a generic SET_FIELD action. Prefer domain-specific actions to keep invariants explicit.
  • Storing derived data. Compute via selectors; it prevents bugs and mismatches.
  • Context value churn. Don’t wrap state and dispatch together in one object; split contexts or memoize values.
  • Giant single reducer. Compose reducers by domain or feature to keep complexity local.

Migration notes: from useState to useReducer

  • Start by writing a reducer that reproduces current transitions. Replace setState calls with dispatch.
  • Keep component signatures stable; expose helper callbacks to smooth adoption.
  • Add TypeScript types for action and state early to catch edge-cases.

A pragmatic checklist

  • Is the state cross-cutting or multi-step? If yes, prefer useReducer.
  • Are transitions enumerated and testable? Define a discriminated union of actions.
  • Is the reducer pure? Move any I/O to a wrapper hook or effect.
  • Are large collections normalized? Use entities + ids and memoized selectors.
  • Is Context split and memoized? Avoid unnecessary re-renders.
  • Do you have quick tests for critical transitions? Add them; they’re cheap and powerful.

Conclusion

useReducer gives you a disciplined, scalable way to manage complex state in React. By modeling your domain carefully, keeping reducers pure, and surrounding them with thoughtful async and performance patterns, you get predictable behavior, testable logic, and components that stay easy to reason about as your app grows.

Related Posts