React useReducer: Complex State Management Patterns

React's useReducer hook gives you Redux-style state management without any external library. When your component state involves multiple sub-values, complex transitions, or actions that depend on previous state, useReducer produces cleaner and more predictable code than a chain of useState calls. This guide covers the core API, real-world action patterns, state machines, combining reducers, and when to reach for useReducer instead of useState.

useReducer Basics

The useReducer hook accepts a reducer function and an initial state, and returns the current state plus a dispatch function. The reducer is a pure function: given the current state and an action, it returns the next state without mutating anything.

import { useReducer } from 'react'

// Define state shape
const initialState = {
  count: 0,
  step: 1,
  history: [],
}

// Reducer: pure function, no side effects
function counterReducer(state, action) {
  switch (action.type) {
    case 'INCREMENT':
      return {
        ...state,
        count: state.count + state.step,
        history: [...state.history, state.count],
      }
    case 'DECREMENT':
      return {
        ...state,
        count: state.count - state.step,
        history: [...state.history, state.count],
      }
    case 'SET_STEP':
      return { ...state, step: action.payload }
    case 'RESET':
      return initialState
    case 'UNDO':
      if (state.history.length === 0) return state
      const prev = state.history[state.history.length - 1]
      return {
        ...state,
        count: prev,
        history: state.history.slice(0, -1),
      }
    default:
      return state
  }
}

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

  return (
    <div>
      <p>Count: {state.count} (step: {state.step})</p>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
      <button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
      <button onClick={() => dispatch({ type: 'UNDO' })}>Undo</button>
      <button onClick={() => dispatch({ type: 'RESET' })}>Reset</button>
      <input
        type="number"
        value={state.step}
        onChange={e => dispatch({ type: 'SET_STEP', payload: +e.target.value })}
      />
    </div>
  )
}
Note: Reducers must be pure — no API calls, no random numbers, no Date.now(). All side effects belong outside the reducer, triggered by dispatched actions.

Action Type Patterns

Consistent action shapes make reducers easier to test and reason about. Two popular conventions are Flux Standard Actions (FSA) and the Redux Toolkit slice pattern. Using string constants or enums prevents typo bugs that cause silent no-ops.

// Action type constants — prevents typos
const ACTIONS = {
  FETCH_START: 'FETCH_START',
  FETCH_SUCCESS: 'FETCH_SUCCESS',
  FETCH_ERROR: 'FETCH_ERROR',
  SET_FILTER: 'SET_FILTER',
  TOGGLE_ITEM: 'TOGGLE_ITEM',
  BULK_DELETE: 'BULK_DELETE',
} as const

// Action creators — keep dispatch calls clean
const actions = {
  fetchStart: () => ({ type: ACTIONS.FETCH_START }),
  fetchSuccess: (data) => ({ type: ACTIONS.FETCH_SUCCESS, payload: data }),
  fetchError: (error) => ({ type: ACTIONS.FETCH_ERROR, payload: error, error: true }),
  setFilter: (filter) => ({ type: ACTIONS.SET_FILTER, payload: filter }),
  toggleItem: (id) => ({ type: ACTIONS.TOGGLE_ITEM, payload: id }),
  bulkDelete: (ids) => ({ type: ACTIONS.BULK_DELETE, payload: ids }),
}

// List reducer with multiple action patterns
function listReducer(state, action) {
  switch (action.type) {
    case ACTIONS.FETCH_START:
      return { ...state, loading: true, error: null }

    case ACTIONS.FETCH_SUCCESS:
      return {
        ...state,
        loading: false,
        items: action.payload,
        filtered: action.payload,
      }

    case ACTIONS.FETCH_ERROR:
      return { ...state, loading: false, error: action.payload.message }

    case ACTIONS.SET_FILTER:
      return {
        ...state,
        filter: action.payload,
        filtered: state.items.filter(item =>
          item.name.toLowerCase().includes(action.payload.toLowerCase())
        ),
      }

    case ACTIONS.TOGGLE_ITEM:
      return {
        ...state,
        selected: state.selected.includes(action.payload)
          ? state.selected.filter(id => id !== action.payload)
          : [...state.selected, action.payload],
      }

    case ACTIONS.BULK_DELETE:
      return {
        ...state,
        items: state.items.filter(item => !action.payload.includes(item.id)),
        filtered: state.filtered.filter(item => !action.payload.includes(item.id)),
        selected: [],
      }

    default:
      return state
  }
}

// Usage
dispatch(actions.fetchSuccess(data))
dispatch(actions.toggleItem(42))

Handling Async Actions

Reducers are synchronous, so async operations like API calls live in event handlers or custom hooks. The pattern is: dispatch a loading action, await the async work, then dispatch success or error. This is the same thunk pattern Redux uses, but without any middleware setup.

const initialFetchState = {
  data: null,
  loading: false,
  error: null,
}

function fetchReducer(state, action) {
  switch (action.type) {
    case 'LOADING': return { ...state, loading: true, error: null }
    case 'SUCCESS': return { loading: false, error: null, data: action.payload }
    case 'ERROR':   return { ...state, loading: false, error: action.payload }
    default:        return state
  }
}

// Custom hook that encapsulates async logic
function useFetch(url) {
  const [state, dispatch] = useReducer(fetchReducer, initialFetchState)

  useEffect(() => {
    let cancelled = false
    dispatch({ type: 'LOADING' })

    fetch(url)
      .then(r => r.json())
      .then(data => {
        if (!cancelled) dispatch({ type: 'SUCCESS', payload: data })
      })
      .catch(err => {
        if (!cancelled) dispatch({ type: 'ERROR', payload: err.message })
      })

    return () => { cancelled = true }
  }, [url])

  return state
}

// Form with async submit
function useFormSubmit(onSubmit) {
  const [state, dispatch] = useReducer(fetchReducer, initialFetchState)

  async function submit(formData) {
    dispatch({ type: 'LOADING' })
    try {
      const result = await onSubmit(formData)
      dispatch({ type: 'SUCCESS', payload: result })
    } catch (err) {
      dispatch({ type: 'ERROR', payload: err.message })
    }
  }

  return { ...state, submit }
}

Combining Reducers

As state grows, splitting the reducer into smaller domain-specific reducers keeps each file manageable. A combineReducers helper delegates each slice of state to its own reducer — just like Redux, but hand-rolled in a few lines.

// Sub-reducers for different state slices
function userReducer(state = {}, action) {
  switch (action.type) {
    case 'SET_USER': return action.payload
    case 'LOGOUT': return {}
    default: return state
  }
}

function cartReducer(state = [], action) {
  switch (action.type) {
    case 'ADD_TO_CART':
      const existing = state.find(item => item.id === action.payload.id)
      if (existing) {
        return state.map(item =>
          item.id === action.payload.id
            ? { ...item, qty: item.qty + 1 }
            : item
        )
      }
      return [...state, { ...action.payload, qty: 1 }]
    case 'REMOVE_FROM_CART':
      return state.filter(item => item.id !== action.payload)
    case 'CLEAR_CART':
      return []
    default:
      return state
  }
}

function uiReducer(state = { sidebarOpen: false, theme: 'dark' }, action) {
  switch (action.type) {
    case 'TOGGLE_SIDEBAR': return { ...state, sidebarOpen: !state.sidebarOpen }
    case 'SET_THEME': return { ...state, theme: action.payload }
    default: return state
  }
}

// Simple combineReducers implementation
function combineReducers(reducers) {
  return function rootReducer(state = {}, action) {
    return Object.keys(reducers).reduce((nextState, key) => {
      nextState[key] = reducers[key](state[key], action)
      return nextState
    }, {})
  }
}

const rootReducer = combineReducers({
  user: userReducer,
  cart: cartReducer,
  ui: uiReducer,
})

function App() {
  const [state, dispatch] = useReducer(rootReducer, undefined)
  // state.user, state.cart, state.ui are all managed independently
}

State Machine Patterns

State machines are perfect for workflows with well-defined states and valid transitions — authentication flows, multi-step wizards, data fetching lifecycle. By encoding which actions are valid in each state, you eliminate impossible states and race conditions.

// Traffic light state machine
const lightMachine = {
  initial: 'red',
  states: {
    red:    { on: { NEXT: 'green' } },
    green:  { on: { NEXT: 'yellow' } },
    yellow: { on: { NEXT: 'red' } },
  },
}

function lightReducer(state, action) {
  const currentState = lightMachine.states[state]
  const nextState = currentState?.on?.[action.type]
  return nextState ?? state   // ignore invalid transitions
}

// Auth flow state machine
const authStates = {
  idle: {
    SUBMIT: 'submitting',
  },
  submitting: {
    SUCCESS: 'authenticated',
    FAILURE: 'error',
  },
  error: {
    SUBMIT: 'submitting',
    RESET: 'idle',
  },
  authenticated: {
    LOGOUT: 'idle',
  },
}

const authInitial = {
  status: 'idle',
  user: null,
  error: null,
}

function authReducer(state, action) {
  const validTransitions = authStates[state.status] ?? {}
  const nextStatus = validTransitions[action.type]

  if (!nextStatus) return state  // invalid action for current state — ignore

  switch (action.type) {
    case 'SUBMIT':
      return { ...state, status: 'submitting', error: null }
    case 'SUCCESS':
      return { status: 'authenticated', user: action.payload, error: null }
    case 'FAILURE':
      return { ...state, status: 'error', error: action.payload }
    case 'LOGOUT':
    case 'RESET':
      return authInitial
    default:
      return state
  }
}

function LoginForm() {
  const [state, dispatch] = useReducer(authReducer, authInitial)

  async function handleSubmit(credentials) {
    dispatch({ type: 'SUBMIT' })
    try {
      const user = await login(credentials)
      dispatch({ type: 'SUCCESS', payload: user })
    } catch (err) {
      dispatch({ type: 'FAILURE', payload: err.message })
    }
  }

  return (
    <div>
      {state.status === 'error' && <p className="error">{state.error}</p>}
      {state.status === 'authenticated'
        ? <p>Welcome, {state.user.name}!</p>
        : <form onSubmit={e => { e.preventDefault(); handleSubmit(new FormData(e.target)) }}>
            <button disabled={state.status === 'submitting'}>
              {state.status === 'submitting' ? 'Logging in...' : 'Login'}
            </button>
          </form>
      }
    </div>
  )
}

useReducer with Context

Pairing useReducer with useContext gives you a lightweight global store — no Redux, no Zustand, no external dependencies. The pattern is: create a context for state and a separate context for dispatch, wrap your tree, and access either or both in any descendant component.

import { createContext, useContext, useReducer } from 'react'

// Separate contexts for state and dispatch — prevents re-renders in dispatch-only consumers
const StoreStateContext = createContext(null)
const StoreDispatchContext = createContext(null)

function storeReducer(state, action) {
  switch (action.type) {
    case 'ADD_NOTIFICATION':
      return { ...state, notifications: [...state.notifications, action.payload] }
    case 'REMOVE_NOTIFICATION':
      return {
        ...state,
        notifications: state.notifications.filter(n => n.id !== action.payload),
      }
    case 'SET_THEME':
      return { ...state, theme: action.payload }
    default:
      return state
  }
}

const initialStore = { notifications: [], theme: 'dark' }

export function StoreProvider({ children }) {
  const [state, dispatch] = useReducer(storeReducer, initialStore)
  return (
    <StoreStateContext.Provider value={state}>
      <StoreDispatchContext.Provider value={dispatch}>
        {children}
      </StoreDispatchContext.Provider>
    </StoreStateContext.Provider>
  )
}

// Convenience hooks
export function useStoreState() {
  const ctx = useContext(StoreStateContext)
  if (!ctx) throw new Error('useStoreState must be inside StoreProvider')
  return ctx
}

export function useStoreDispatch() {
  const ctx = useContext(StoreDispatchContext)
  if (!ctx) throw new Error('useStoreDispatch must be inside StoreProvider')
  return ctx
}

// Usage in any component
function NotificationBell() {
  const { notifications } = useStoreState()
  return <span>{notifications.length}</span>
}

function AddNotificationButton() {
  const dispatch = useStoreDispatch()   // does NOT re-render when state changes
  return (
    <button onClick={() => dispatch({
      type: 'ADD_NOTIFICATION',
      payload: { id: Date.now(), message: 'Hello!' }
    })}>
      Notify
    </button>
  )
}

useReducer vs useState

Choose useReducer when: state has multiple sub-values that change together; next state depends on previous state in complex ways; the same state update logic is needed in multiple event handlers; you want to extract update logic to a testable pure function; or when debugging, you need to log every state transition. Stick with useState for simple booleans, numbers, or strings that change independently.

// Prefer useState — independent, simple values
const [isOpen, setIsOpen] = useState(false)
const [name, setName] = useState('')
const [count, setCount] = useState(0)

// Prefer useReducer — related values, complex transitions
const [formState, dispatch] = useReducer(formReducer, {
  values: { email: '', password: '' },
  errors: {},
  touched: {},
  isSubmitting: false,
  submitCount: 0,
})

// Lazy initializer — expensive initial state computation runs once
function initState(userPrefs) {
  return {
    theme: userPrefs.theme ?? 'dark',
    language: userPrefs.language ?? 'en',
    items: computeInitialItems(userPrefs),  // expensive, runs once
  }
}

const [state, dispatch] = useReducer(reducer, userPrefs, initState)

// Testing reducers is straightforward — pure functions, no mocks needed
describe('cartReducer', () => {
  it('adds item to empty cart', () => {
    const state = cartReducer([], { type: 'ADD_TO_CART', payload: { id: 1, name: 'Book', price: 9.99 } })
    expect(state).toHaveLength(1)
    expect(state[0].qty).toBe(1)
  })

  it('increments qty for existing item', () => {
    const initial = [{ id: 1, name: 'Book', price: 9.99, qty: 2 }]
    const state = cartReducer(initial, { type: 'ADD_TO_CART', payload: { id: 1, name: 'Book', price: 9.99 } })
    expect(state[0].qty).toBe(3)
  })
})

TypeScript Patterns

TypeScript's discriminated union types make reducers fully type-safe. When you switch on action.type, TypeScript narrows the action type automatically, so each case gets proper payload typing without any casting.

// Discriminated union for actions
type CounterAction =
  | { type: 'INCREMENT' }
  | { type: 'DECREMENT' }
  | { type: 'SET_STEP'; payload: number }
  | { type: 'RESET' }

interface CounterState {
  count: number
  step: number
}

// TypeScript narrows action.payload type in each case
function typedReducer(state: CounterState, action: CounterAction): 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 }   // payload: number ✓
    case 'RESET':
      return { count: 0, step: 1 }
  }
}

// Generic fetch state
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 createFetchReducer<T>() {
  return function reducer(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 }
    }
  }
}

// Reusable typed fetch reducer
const userFetchReducer = createFetchReducer<User>()
const [userState, dispatch] = useReducer(userFetchReducer, { status: 'idle' })