Skip to content

React · Hooks

React useReducer Hook — Complete Guide for Interviews

7 min read Updated 2026-06-23 Share:

Practice useReducer interview questions

What useReducer is and why it exists

useState is perfect for simple, independent values. But when a component's state has several fields that change together — a form's values plus its loading/error/success status, for example — you end up calling multiple setters in every handler and the logic scatters across the component. useReducer centralizes that logic in a single pure function: the reducer. Every state transition is a dispatch away, and the entire update logic lives in one testable place.

Interviewers reach for useReducer questions when they want to know whether you can model state changes explicitly rather than imperatively calling setters.

The API

const [state, dispatch] = useReducer(reducer, initialState)
  • reducer — a pure function (state, action) => nextState.
  • initialState — the value for the first render.
  • state — the current state.
  • dispatch — sends an action to the reducer; React calls the reducer with the current state and the action, then re-renders with the returned state.

React guarantees that dispatch has a stable identity across renders — you can pass it through context or include it in dependency arrays without causing re-renders.

The reducer function

A reducer takes the current state and an action and returns the next state. It must be pure: no side effects, no mutation of the state argument, deterministic output.

function cartReducer(state, action) {
  switch (action.type) {
    case 'add_item':
      return { ...state, items: [...state.items, action.item] }
    case 'remove_item':
      return { ...state, items: state.items.filter(i => i.id !== action.id) }
    case 'clear':
      return { ...state, items: [] }
    default:
      return state // always return current state for unknown actions
  }
}

Two rules worth memorizing for interviews: (1) return a new object/array, never mutate the argument; (2) always handle default by returning state — returning undefined causes subtle render bugs.

Action conventions

Actions are plain objects. The type field identifies what happened; any extra data is usually called payload.

dispatch({ type: 'add_item', item: { id: 1, name: 'Widget', qty: 2 } })
dispatch({ type: 'remove_item', id: 1 })

Define type values as named constants or a TypeScript union to catch typos at compile time — a misspelled string silently falls through to default with no runtime error.

type CartAction =
  | { type: 'add_item'; item: Item }
  | { type: 'remove_item'; id: number }
  | { type: 'clear' }

When to prefer useReducer over useState

Three signals that you should reach for useReducer:

  1. Multiple sub-values that change together. If you always call three setters at once (setLoading, setError, setData), a single dispatch is cleaner.
  2. Complex update logic. If your useState handlers contain if/else trees or switch statements, move that logic into the reducer where it's testable.
  3. You want to share update logic. A reducer is a plain function — easy to import, compose, and unit-test without rendering.
// before: three scattered setters
function handleFetch() {
  setLoading(true)
  setError(null)
  setData(null)
}

// after: one dispatch, all transitions in one place
dispatch({ type: 'fetch_start' })

The initializer function (third argument)

useReducer(reducer, arg, init) — the optional third argument is a function that React calls as init(arg) on the first render to compute the initial state. This avoids running an expensive computation on every render (the second-argument expression is evaluated each time the component renders, even though it's only used once).

function parseSettings(raw) {
  return { theme: raw.theme ?? 'dark', lang: raw.lang ?? 'en' } // expensive parse
}

const [settings, dispatch] = useReducer(settingsReducer, rawData, parseSettings)
// parseSettings(rawData) only runs on mount

The initializer also enables a clean reset action — the reducer can call init(action.payload) to rebuild fresh state from a starting value.

Sharing dispatch via context

dispatch has a stable identity, so putting it in a context doesn't cause consumers to re-render on every state change. The pattern: a Provider holds the useReducer call and exposes both state and dispatch through context.

const StoreCtx = createContext(null)

function StoreProvider({ children }) {
  const [state, dispatch] = useReducer(reducer, initial)
  return (
    <StoreCtx.Provider value={{ state, dispatch }}>
      {children}
    </StoreCtx.Provider>
  )
}

function CartButton() {
  const { state, dispatch } = useContext(StoreCtx)
  return (
    <button onClick={() => dispatch({ type: 'clear' })}>
      Clear ({state.items.length})
    </button>
  )
}

For large apps, split the context: one for state and one for dispatch. Components that only dispatch don't re-render when state changes.

Testing reducers

Because a reducer is a pure function, you test it with zero React infrastructure — just call it and assert on the return value.

import { cartReducer } from './cartReducer'

test('adds an item', () => {
  const state = { items: [] }
  const next = cartReducer(state, { type: 'add_item', item: { id: 1, name: 'A' } })
  expect(next.items).toHaveLength(1)
  expect(next.items[0].id).toBe(1)
})

test('unknown action returns same state', () => {
  const state = { items: [] }
  expect(cartReducer(state, { type: 'unknown' })).toBe(state) // same reference
})

Testing the reducer independently is useReducer's key advantage over useState — all business logic is in one pure function that exercises instantly.

Immer for simpler updates

Deep immutable updates (spreading nested objects) get verbose. Immer's produce wraps your reducer so you can write mutation-looking code on a draft, and it produces a correct immutable result.

import produce from 'immer'

const cartReducer = produce((draft, action) => {
  switch (action.type) {
    case 'add_item':
      draft.items.push(action.item)  // mutation syntax, but produces a new state
      break
    case 'update_qty':
      const item = draft.items.find(i => i.id === action.id)
      if (item) item.qty = action.qty
      break
  }
})

Redux Toolkit uses Immer internally for exactly this reason. If you're rolling your own useReducer + context store, Immer is worth adding when update logic becomes nested.

useReducer vs Redux

FeatureuseReducer + contextRedux Toolkit
Scopecomponent treeglobal singleton
DevToolsnoneyes, time travel
Middlewarenonethunk, saga, RTK Query
Boilerplatelowlow (RTK)
SelectorsmanualcreateSelector + useSelector

useReducer + context is often enough for module-level or feature-scoped state. When you need cross-cutting global state with selectors (so only interested components re-render), async middleware, or the Redux DevTools for debugging, Redux Toolkit earns its dependency.

Common interview questions at a glance

  • What does dispatch do? Sends an action to the reducer; React calls reducer(currentState, action) and re-renders with the returned state.
  • What must a reducer not do? Have side effects or mutate its state argument.
  • How is dispatch stable? React guarantees the same dispatch reference across renders — safe in dependency arrays and context.
  • When would you use useReducer over useState? Multiple fields that change together, complex branchy update logic, or when you want unit-testable transitions.
  • How do you reset state? Handle a reset action type that returns initialState, or call the initializer function with a starting value.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel