useReducer & State Machines

Med

useReducer manages complex state logic by dispatching actions to a reducer function. It is preferred over useState when state transitions depend on previous state, when multiple sub-values are related, or when you want to centralize state logic for testing and reasoning.

Interactive Visualization

Key Points

  • Takes a reducer function and initial state, returns [state, dispatch]
  • Reducer is a pure function: (state, action) => newState
  • Actions describe what happened, the reducer decides how state changes
  • TypeScript discriminated unions make actions type-safe
  • Reducers are easy to test in isolation since they are pure functions
  • useReducer can be combined with Context to create a mini state management system

Code Examples

Typed Reducer with Discriminated Unions

import { useReducer } from 'react'

interface TodoState {
  todos: { id: number; text: string; done: boolean }[]
  nextId: number
}

type TodoAction =
  | { type: 'ADD'; text: string }
  | { type: 'TOGGLE'; id: number }
  | { type: 'DELETE'; id: number }

function todoReducer(state: TodoState, action: TodoAction): TodoState {
  switch (action.type) {
    case 'ADD':
      return {
        todos: [...state.todos, { id: state.nextId, text: action.text, done: false }],
        nextId: state.nextId + 1,
      }
    case 'TOGGLE':
      return {
        ...state,
        todos: state.todos.map((t) =>
          t.id === action.id ? { ...t, done: !t.done } : t
        ),
      }
    case 'DELETE':
      return {
        ...state,
        todos: state.todos.filter((t) => t.id !== action.id),
      }
  }
}

function TodoApp() {
  const [state, dispatch] = useReducer(todoReducer, {
    todos: [],
    nextId: 1,
  })

  return (
    <div>
      <button onClick={() => dispatch({ type: 'ADD', text: 'New todo' })}>
        Add
      </button>
      {state.todos.map((todo) => (
        <div key={todo.id}>
          <span
            onClick={() => dispatch({ type: 'TOGGLE', id: todo.id })}
            style={{ textDecoration: todo.done ? 'line-through' : 'none' }}
          >
            {todo.text}
          </span>
        </div>
      ))}
    </div>
  )
}

Discriminated union actions ensure TypeScript narrows the payload type in each case, making invalid actions impossible to dispatch

Async State Machine

import { useReducer } from 'react'

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

type AsyncAction<T> =
  | { type: 'FETCH' }
  | { type: 'RESOLVE'; data: T }
  | { type: 'REJECT'; error: string }
  | { type: 'RESET' }

function asyncReducer<T>(
  state: AsyncState<T>,
  action: AsyncAction<T>
): AsyncState<T> {
  switch (action.type) {
    case 'FETCH': return { status: 'loading' }
    case 'RESOLVE': return { status: 'success', data: action.data }
    case 'REJECT': return { status: 'error', error: action.error }
    case 'RESET': return { status: 'idle' }
  }
}

function useAsync<T>() {
  return useReducer(asyncReducer<T>, { status: 'idle' } as AsyncState<T>)
}

Discriminated union state types make invalid states unrepresentable, guaranteeing data exists only when status is success

Common Mistakes

  • Mutating the state object inside the reducer instead of returning a new one
  • Using useReducer for simple toggle or counter state where useState is cleaner
  • Putting async logic inside the reducer which must be a pure function

Interview Tips

  • Compare useState vs useReducer: useReducer shines when state transitions are complex or interdependent
  • Explain how discriminated unions prevent invalid state combinations at the type level
  • Discuss the pattern of useReducer + Context as a lightweight Redux alternative

Related Concepts