Skip to content

useReducer Interview Questions & Answers

16 questions Updated 2026-06-23 Share:

React useReducer interview questions — reducer functions, dispatch, action types, initializers, context integration, and when to prefer useReducer over useState.

Read the in-depth guideReact useReducer Hook — Complete Guide for Interviews(opens in new tab)
16 of 16

useReducer returns a pair: the current state and a dispatch function. You dispatch action objects to the reducer, which computes and returns the next state. React then re-renders with that new state.

const [state, dispatch] = useReducer(reducer, initialState)
dispatch({ type: 'increment' })

The reducer is a pure function (state, action) => nextState. The initialState is used on the first render. This pattern mirrors the way Redux works but lives entirely inside a single component (or shared via context).

A reducer takes the current state and an action and returns the next state. It must be pure — no side effects, no mutating the state argument, same inputs always produce the same output.

function counterReducer(state, action) {
  switch (action.type) {
    case 'increment': return { count: state.count + 1 }
    case 'decrement': return { count: state.count - 1 }
    case 'reset':     return { count: 0 }
    default:          return state // always handle unknown actions
  }
}

Return the existing state unchanged in the default case — returning undefined causes subtle bugs. Always build and return a new object rather than mutating state.

Prefer useReducer when: (1) state has multiple sub-fields that update together, (2) the next state depends on complex logic over the previous one, or (3) you want to centralize update logic so it's easy to test without rendering.

// useState: scattered setters, hard to audit
setLoading(true); setError(null); setData(null)

// useReducer: one dispatch, one place to read all transitions
dispatch({ type: 'fetch_start' })

For a simple counter or toggle, useState is cleaner. When you find yourself calling multiple setters together or your update logic is branchy, useReducer makes intent clearer and the logic testable in isolation.

The optional third argument is an initializer function. React passes initialArg through it on the first render to compute the initial state — just like lazy initialization in useState.

function init(initialCount) {
  return { count: initialCount }
}
const [state, dispatch] = useReducer(reducer, 0, init)
// initial state: { count: 0 }

The initializer is useful for computing expensive initial values, or for allowing a "reset to initial" action — the reducer can call init(action.payload) to rebuild fresh initial state from a fresh argument.

Action types are string identifiers for what happened. Defining them as named constants prevents silent typos: a misspelled string dispatches to the default case with no error, while a misspelled constant causes a ReferenceError.

// prone to typos
dispatch({ type: 'incrment' }) // silently hits default

// constants catch typos at the source
const INC = 'increment'
dispatch({ type: INC })

In larger codebases, co-locate the constants with the reducer or use TypeScript union types for the action shape so the compiler enforces valid combinations.

Combine useReducer with useContext — pass dispatch (and optionally state) through a context so any descendant can dispatch without prop drilling.

const StoreContext = createContext(null)

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

function Counter() {
  const { state, dispatch } = useContext(StoreContext)
  return <button onClick={() => dispatch({ type: 'increment' })}>{state.count}</button>
}

dispatch has stable identity across renders (React guarantees this), so putting it in context doesn't cause spurious re-renders.

Handle a dedicated reset action type that returns the initial state. If the initial state was computed by an initializer, call it from within the reducer.

const initialState = { count: 0, text: '' }

function reducer(state, action) {
  switch (action.type) {
    case 'increment': return { ...state, count: state.count + 1 }
    case 'reset':     return initialState  // back to start
  }
}
dispatch({ type: 'reset' })

When initial state is dynamic (derived from props), pass it in the action payload and return it: case 'reset': return action.payload.

Because a reducer is a pure function, you test it directly — no React, no rendering, no mocking.

import { counterReducer } from './counterReducer'

test('increment adds 1', () => {
  const next = counterReducer({ count: 5 }, { type: 'increment' })
  expect(next.count).toBe(6)
})

test('unknown action returns current state', () => {
  const state = { count: 5 }
  expect(counterReducer(state, { type: 'unknown' })).toBe(state)
})

This is one of useReducer's key advantages: all your update logic is in a plain function you can cover with unit tests before wiring it up.

Yes. Each call is independent and manages a separate slice of state. Use multiple reducers when different parts of a component's state evolve via unrelated logic.

const [formState, formDispatch]   = useReducer(formReducer, initialForm)
const [uiState,   uiDispatch]     = useReducer(uiReducer,   initialUi)

Splitting avoids one monolithic reducer with unrelated cases. It's similar to splitting useState calls for independent values.

Add any extra data as properties on the action object — typically called payload by convention (borrowed from Flux/Redux).

dispatch({ type: 'set_user', payload: { id: 1, name: 'Ada' } })

// reducer
case 'set_user': return { ...state, user: action.payload }

The payload shape is up to you — some teams prefer { type, id, name } instead of nesting under payload. Consistency matters more than the exact shape; pick one convention and stick to it.

Immer's produce wraps a reducer so you can write mutating code on a draft, and it produces a correctly immutable new state behind the scenes.

import produce from 'immer'

const reducer = produce((draft, action) => {
  switch (action.type) {
    case 'add_item':
      draft.items.push(action.item) // looks like mutation, but it's safe
      break
    case 'update_city':
      draft.user.address.city = action.city // deep update without spreading
      break
  }
})

This eliminates the spreading pyramid for deeply nested state without sacrificing immutability. Redux Toolkit uses Immer internally for the same reason.

useReducer gives you the same reducer pattern locally, but lacks Redux's ecosystem: global store singleton, devtools time-travel, middleware for async logic, and selector memoization.

useReducer Redux Toolkit
Scope component / context tree entire app
DevTools none yes
Middleware none thunk, saga, etc.
Async manually via effects built-in

For local or module-level state, useReducer + context is often enough. For cross-cutting, high-churn global state, Redux Toolkit or Zustand adds more infrastructure with less boilerplate.

Mutating the state object and returning it gives React the same reference, so it bails out and skips the re-render — your UI appears frozen even though the data changed.

// broken: mutates the original, same reference returned
case 'push':
  state.items.push(action.item) // mutates!
  return state                  // same ref -> no re-render

// correct: new array, new object
case 'push':
  return { ...state, items: [...state.items, action.item] }

This is the same rule as useState: always return a new object/array for changed values. Immer enforces this for you automatically.

Yes. In React 18, all state updates — including multiple dispatch calls — inside event handlers and async callbacks are automatically batched into a single re-render.

function handleReset() {
  dispatch({ type: 'reset_form' })
  dispatch({ type: 'clear_errors' })
  // ONE re-render in React 18, not two
}

In React 17, only updates inside React event handlers were batched; async callbacks caused separate renders per dispatch. If you need to force synchronous intermediate renders (rarely), use flushSync from react-dom.

React may call the reducer more than once in Strict Mode (development), and concurrent features can discard and replay state updates. A reducer with side effects (API calls, timers, random numbers, mutations) would break or run the side effect multiple times unpredictably.

// broken: side effect in reducer
case 'save':
  fetch('/api/save', { body: JSON.stringify(state) }) // runs on every replay
  return state

// correct: reducer only computes state
case 'save': return { ...state, saving: true }
// then fire the request in a useEffect that watches `saving`

Keep reducers pure — dispatch + effect is the correct pattern for actions that need async work alongside them.

When the initial state requires an expensive computation (parsing a large JSON blob, reading from localStorage, complex filtering), pass it as the third argument so it only runs once — not on every render like a value expression would.

function buildInitialState(data) {
  return { items: parse(data), selected: null } // expensive parse
}

const [state, dispatch] = useReducer(reducer, rawData, buildInitialState)
// buildInitialState(rawData) runs once on mount

Without the initializer, buildInitialState(rawData) in the second argument would still only be used once (the initialState arg is also only read once), so the main win is clarity and the ability to reuse the function for reset actions.

More ways to practice

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

or
Join our WhatsApp Channel