Intermediate0 questionsFull Guide

useReducer Hook — Complete React Interview Guide

Learn useReducer for managing complex state with named transitions — and how it pairs with useContext to replace Redux in mid-sized apps.

The Mental Model

Think of a vending machine. You press a button (an action), and the machine's internal mechanism (the reducer) takes the machine's current state and your button press, then produces a new state. The machine doesn't let you reach inside and change its parts directly. You can only interact with it through its defined interface — the buttons. useReducer works identically. You have a current state (what the vending machine currently has inside). You dispatch an action (press a button). The reducer function — a pure function you write — takes the current state and the action, and returns the next state. You never mutate state directly. You describe what happened, and the reducer decides what changes. The key difference from useState: instead of calling setCount(42) — directly specifying the new value — you call dispatch({ type: 'INCREMENT' }) — describing what happened. The reducer then decides what INCREMENT means for the current state. This separation of "what happened" from "what changes" makes complex state logic readable, testable, and debuggable. When should you reach for useReducer instead of useState? When multiple state fields update together based on the same event. When the next state depends on the previous state in non-trivial ways. When you want to be able to test your state logic independently of your components. When you want to name your transitions.

The Explanation

The anatomy of useReducer

import { useReducer } from 'react'
 
// 1. Define state shape
const initialState = { count: 0, step: 1 }
 
// 2. Define reducer — pure function: (state, action) => newState
function counterReducer(state, action) {
  switch (action.type) {
    case 'INCREMENT':
      return { ...state, count: state.count + state.step }
    case 'DECREMENT':
      return { ...state, count: state.count - state.step }
    case 'RESET':
      return initialState
    case 'SET_STEP':
      return { ...state, step: action.payload }
    default:
      throw new Error('Unknown action: ' + action.type)
  }
}
 
// 3. Use in component
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: 'RESET' })}>Reset</button>
      <input
        type="number"
        value={state.step}
        onChange={e => dispatch({ type: 'SET_STEP', payload: Number(e.target.value) })}
      />
    </div>
  )
}

useReducer vs useState — the decision

Use useState whenUse useReducer when
Simple, independent valuesMultiple fields update together
Direct value replacementNext state depends on previous in complex ways
2-3 unrelated state variablesState transitions benefit from names
No complex logic neededState logic is testable in isolation
No interdependenciesSeveral handlers share state mutations
// Signal to switch: you're calling 3+ setters in one handler
function handleSubmit() {
  setLoading(true)       // ← 4 setters
  setError(null)
  setData(null)
  setSubmitted(false)
  // This should be one dispatch({ type: 'SUBMIT_START' })
}

Complex form state — the classic useReducer use case

const initialFormState = {
  values:    { name: '', email: '', password: '' },
  errors:    {},
  loading:   false,
  submitted: false,
}
 
function formReducer(state, action) {
  switch (action.type) {
    case 'FIELD':
      return {
        ...state,
        values: { ...state.values, [action.field]: action.value },
        errors: { ...state.errors, [action.field]: '' }  // clear field error on change
      }
    case 'VALIDATE':
      return { ...state, errors: action.errors }
    case 'SUBMIT_START':
      return { ...state, loading: true, errors: {} }
    case 'SUBMIT_SUCCESS':
      return { ...state, loading: false, submitted: true }
    case 'SUBMIT_ERROR':
      return { ...state, loading: false, errors: action.errors }
    case 'RESET':
      return initialFormState
    default:
      return state
  }
}
 
function SignupForm() {
  const [state, dispatch] = useReducer(formReducer, initialFormState)
 
  async function handleSubmit(e) {
    e.preventDefault()
    const errors = validate(state.values)
    if (Object.keys(errors).length) {
      dispatch({ type: 'VALIDATE', errors })
      return
    }
 
    dispatch({ type: 'SUBMIT_START' })
    try {
      await api.signup(state.values)
      dispatch({ type: 'SUBMIT_SUCCESS' })
    } catch (err) {
      dispatch({ type: 'SUBMIT_ERROR', errors: { global: err.message } })
    }
  }
 
  return (
    <form onSubmit={handleSubmit}>
      <input
        value={state.values.name}
        onChange={e => dispatch({ type: 'FIELD', field: 'name', value: e.target.value })}
      />
      {state.errors.global && <p className="error">{state.errors.global}</p>}
      <button disabled={state.loading}>
        {state.loading ? 'Signing up...' : 'Sign Up'}
      </button>
    </form>
  )
}

Lazy initialization — third argument

function init(initialCount) {
  // Called once on mount — can do expensive setup
  return { count: initialCount, history: [] }
}
 
function Counter({ initialCount = 0 }) {
  // Third arg: init function called with second arg (initialCount)
  const [state, dispatch] = useReducer(counterReducer, initialCount, init)
  // init(0) → { count: 0, history: [] }
 
  // Also enables clean reset with dispatch
  function handleReset() {
    dispatch({ type: 'reset', payload: initialCount })
  }
}
 
function counterReducer(state, action) {
  switch (action.type) {
    case 'increment': return { count: state.count + 1, history: [...state.history, state.count] }
    case 'reset':     return init(action.payload)  // reuse init function!
  }
}

Testing reducers in isolation

Pure reducer functions can be unit tested directly without mounting any component — no React Test Renderer, no act(), no async rendering:

// The reducer — no React imports
function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD':
      const existing = state.items.find(i => i.id === action.item.id)
      if (existing) return {
        ...state,
        items: state.items.map(i => i.id === action.item.id ? { ...i, qty: i.qty + 1 } : i)
      }
      return { ...state, items: [...state.items, { ...action.item, qty: 1 }] }
    case 'REMOVE':
      return { ...state, items: state.items.filter(i => i.id !== action.id) }
    case 'CLEAR':
      return { items: [] }
    default:
      return state
  }
}
 
// Pure unit tests — zero React overhead
describe('cartReducer', () => {
  it('adds a new item', () => {
    const state = { items: [] }
    const next  = cartReducer(state, { type: 'ADD', item: { id: 1, name: 'Book', price: 10 } })
    expect(next.items).toHaveLength(1)
    expect(next.items[0].qty).toBe(1)
  })
 
  it('increments qty for existing item', () => {
    const state = { items: [{ id: 1, qty: 1 }] }
    const next  = cartReducer(state, { type: 'ADD', item: { id: 1 } })
    expect(next.items[0].qty).toBe(2)
  })
})

useReducer + useContext — the mini-Redux pattern

// Full global state pattern without any external library
const StoreContext    = createContext(null)
const DispatchContext = createContext(null)
 
const initialState = { user: null, theme: 'light', cart: [] }
 
function appReducer(state, action) {
  switch (action.type) {
    case 'LOGIN':       return { ...state, user: action.user }
    case 'LOGOUT':      return { ...state, user: null, cart: [] }
    case 'TOGGLE_THEME': return { ...state, theme: state.theme === 'light' ? 'dark' : 'light' }
    case 'ADD_TO_CART': return { ...state, cart: [...state.cart, action.item] }
    default:            return state
  }
}
 
export function AppProvider({ children }) {
  const [state, dispatch] = useReducer(appReducer, initialState)
  return (
    <DispatchContext.Provider value={dispatch}>  {/* stable — never re-renders */}
      <StoreContext.Provider value={state}>
        {children}
      </StoreContext.Provider>
    </DispatchContext.Provider>
  )
}
 
// Custom hooks for each concern
export const useStore    = () => useContext(StoreContext)
export const useDispatch = () => useContext(DispatchContext)
 
// Any component — reads state
function CartCount() {
  const { cart } = useStore()
  return <span>{cart.length}</span>
}
 
// Any component — dispatches actions
function AddButton({ item }) {
  const dispatch = useDispatch()
  return <button onClick={() => dispatch({ type: 'ADD_TO_CART', item })}>Add</button>
  // This component NEVER re-renders when state changes — DispatchContext is stable
}

Immer integration — eliminating spread boilerplate

import produce from 'immer'
 
// Without Immer — verbose nested updates
function reducer(state, action) {
  switch (action.type) {
    case 'UPDATE_CITY':
      return {
        ...state,
        user: { ...state.user, address: { ...state.user.address, city: action.city } }
      }
  }
}
 
// With Immer — write mutating code, get immutable result
const reducer = produce((draft, action) => {
  switch (action.type) {
    case 'UPDATE_CITY':
      draft.user.address.city = action.city  // looks like mutation — Immer handles immutability!
      break
    case 'ADD_TAG':
      draft.post.tags.push(action.tag)  // direct array push — Immer makes it immutable
      break
  }
})

Common Misconceptions

⚠️

Many developers think useReducer is just a more complex useState — but it's a fundamentally different model. useState says "set this value to X". useReducer says "this event happened — you decide what changes". The reducer centralises all state logic in one place, making transitions explicit, named, and testable.

⚠️

Many developers think reducers can contain side effects like API calls — but reducers must be pure functions. No fetch, no localStorage, no console.log with side effects. Side effects go in useEffect or in the event handlers that dispatch actions. The reducer receives an action result (e.g., fetched data) as part of the action payload — it doesn't perform the fetch itself.

⚠️

Many developers think dispatch is asynchronous like setState — but dispatch is synchronous. After calling dispatch, the state update is scheduled immediately (not applied synchronously), but dispatch itself returns undefined and doesn't give you a way to await the state update. The pattern for async is: dispatch loading action → perform async operation → dispatch success/error action.

⚠️

Many developers think using useReducer requires TypeScript — the pattern works in plain JavaScript too. TypeScript makes it better (action union types, exhaustive switch, typed initial state) but it's not required. The pattern's benefits come from the architecture, not the type system.

⚠️

Many developers think useReducer is always more complex than useState — for simple state, it is. But for forms, multi-step flows, undo/redo, and loading/error/success patterns, useReducer is actually simpler because all the logic lives in one function instead of scattered across multiple event handlers.

Where You'll See This in Real Code

Shopping carts use useReducer with actions like ADD_ITEM, REMOVE_ITEM, UPDATE_QUANTITY, APPLY_COUPON, and CLEAR_CART. Each action updates multiple fields atomically — total, count, discounts, and the items array all update consistently in a single reducer call without risks of partial state corruption.

Multi-step checkout flows use useReducer to manage steps, form data, validation errors, loading states, and payment status in a single reducible state object. Each step transition dispatches an action, making it trivial to go back to any previous step and restore its exact state.

Undo/redo functionality is the textbook useReducer use case. The state shape includes a present and a history array. UNDO pops the last action from history and reverts to the previous state. REDO reapplies. This is impossible to implement cleanly with useState — useReducer's centralised state transitions make it natural.

Real-time games (turn-based, puzzles) use useReducer where each player action is a dispatch call. The game board state, current player, score, and game phase all update together in the reducer. The pure function structure makes replaying a game from a log of actions trivial.

Data tables with sorting, filtering, pagination, and column visibility use useReducer where SORT, FILTER, PAGINATE, TOGGLE_COLUMN are all named actions. This makes the table's state machine explicit and makes it easy to add features like saved table configurations or URL-synced state.

Interview Cheat Sheet

  • useReducer(reducer, initialState) → [state, dispatch]
  • reducer is a pure function: (state, action) => newState — no side effects, no API calls
  • dispatch({ type: 'ACTION_TYPE', payload: data }) sends an action to the reducer
  • dispatch is always stable — the same function reference for the component's lifetime
  • Use when: multiple fields update together, transitions need names, logic is testable in isolation
  • Signal to switch from useState: 3+ setters called in one event handler
  • Lazy init: useReducer(reducer, initialArg, initFn) — initFn(initialArg) runs once on mount
  • Reuse initFn in reset actions: dispatch({ type: 'RESET', payload: initial }) → return initFn(payload)
  • Reducers are pure functions — test them directly without mounting components
  • useReducer + useContext = mini-Redux without external dependencies
  • Put dispatch in its own context — it's stable, its consumers never re-render on state changes
  • Immer's produce() eliminates spread boilerplate in complex nested state updates
💡

How to Answer in an Interview

  • 1.When asked "when do you use useReducer vs useState", give the concise trigger rule: "I switch to useReducer when a single event needs to update multiple state fields together, when I want to name my transitions, or when I want to test the state logic independently. The clearest signal is finding myself calling 3-4 setState functions in a single handler."
  • 2.Show the testability advantage in interviews: "The biggest win from useReducer is that the reducer is a pure JavaScript function. I can test every state transition without mounting a component, without async, without a testing library — just import the reducer and call it with inputs." This signals production-level thinking about code quality.
  • 3.The lazy initialisation third argument is an advanced question. Cover it with the reset-reuse pattern: "The init function can be reused in reset actions — dispatch({ type: 'reset', payload: initialCount }) returns init(initialCount). This avoids duplicating the initial state construction logic."
  • 4.Connect useReducer to Redux for context: "useReducer + useContext essentially implements the Redux pattern without the library. It's appropriate for medium-complexity global state. For large apps with middleware, time-travel debugging, or complex async flows, I'd use Redux Toolkit — but for most apps, useReducer + Context is sufficient."
  • 5.When discussing async actions with useReducer: "Reducers must be pure — no API calls. For async, I dispatch a 'LOADING' action from the event handler, await the API call, then dispatch 'SUCCESS' or 'ERROR' with the result as payload. The reducer only handles the state transitions, not the async operation itself."

Practice Questions

No questions tagged to this topic yet.

Related Topics

useState Hook — Complete React Interview Guide
Beginner·4–8 Qs
useCallback Hook — Complete React Interview Guide
Intermediate·4–8 Qs
useMemo Hook — Complete React Interview Guide
Intermediate·4–8 Qs
useEffect Hook — Complete React Interview Guide
Beginner·4–8 Qs
useContext Hook — Complete React Interview Guide
Intermediate·4–8 Qs
useRef Hook — Complete React Interview Guide
Beginner·4–8 Qs
🎯

Can you answer these under pressure?

Reading answers is not the same as knowing them. Practice saying them out loud with AI feedback — that's what builds real interview confidence.

Practice Free →Try Output Quiz