“When would you use useReducer instead of useState?” The weak answer is “when state is complicated”. The answer interviewers are listening for is about invariants: rules that must always be true, such as “a request cannot be loading and failed at the same time”, and having exactly one place in the code that enforces them.
A typical follow-up shows a component with isLoading, isError and data as separate state variables and asks what can go wrong. The answer is that nothing stops those variables from contradicting each other.
Before you start
You should know useState, the functional update form and the idea that state updates must be immutable. Basic familiarity with switch statements and discriminated unions (a type field that tells you which shape an object has) helps. The API is the same in React 18 and 19.
The short answer
useReducer(reducer, initialState) moves state transitions into a pure function (state, action) => nextState. Choose it when several pieces of state change together, when the next state depends on rules about the current state, or when you want transitions to be testable in isolation. Model mutually exclusive situations with a single status field rather than multiple booleans, and let the reducer refuse invalid transitions by returning the current state unchanged. Components then dispatch what happened, and the reducer decides what that means.
How it works
React calls your reducer while processing the update queue, with the latest state and the dispatched action. It must be pure: no mutation, no side effects, the same output for the same input. In development, Strict Mode calls it twice to check that.
Compare two designs for a data request. With separate booleans:
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [data, setData] = useState(null);There are eight combinations of these three “flags” and most are nonsense, such as loading with an error and data all at once. Every handler must remember to set all three correctly. With one status and a reducer:
const initialState = { status: 'idle', data: null, error: null };
function fetchReducer(state, action) {
switch (action.type) {
case 'start':
if (state.status === 'loading') return state;
return { status: 'loading', data: state.data, error: null };
case 'resolve':
if (state.status !== 'loading') return state;
return { status: 'success', data: action.data, error: null };
case 'reject':
if (state.status !== 'loading') return state;
return { status: 'error', data: state.data, error: action.error };
default:
throw new Error(`Unknown action: ${action.type}`);
}
}A resolve that arrives when nothing is loading is ignored. Returning the same state object tells React nothing changed, so it can skip re-rendering.
Step-by-step walkthrough
Step 1: Write down the states and allowed transitions
Before writing code, list them: idle -> loading, loading -> success, loading -> error, success -> loading (refresh), error -> loading (retry). Anything not on the list is invalid. This is a tiny state machine, and drawing it is a strong move in an interview.
Step 2: Name actions after events, not setters
Dispatch { type: 'resolve', data }, not { type: 'setLoading', value: false }. Event-style actions keep the transition logic in the reducer. Setter-style actions push it back into components, which is exactly what you were trying to avoid.
Step 3: Wire it into the component
function Orders() {
const [state, dispatch] = useReducer(fetchReducer, initialState);
async function load() {
dispatch({ type: 'start' });
try {
const res = await fetch('/api/orders');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
dispatch({ type: 'resolve', data: await res.json() });
} catch (error) {
dispatch({ type: 'reject', error: error.message });
}
}
if (state.status === 'loading' && !state.data) return <p>Loading</p>;
if (state.status === 'error') return <p role="alert">{state.error} <button onClick={load}>Retry</button></p>;
return <OrderTable rows={state.data ?? []} onRefresh={load} />;
}The fetching is in the event handler; the reducer only decides the next state. dispatch is stable, so it can be passed down or used in effects without dependency churn.
Step 4: Test the reducer as a plain function
Because the reducer is pure JavaScript, you can test every transition without React:
import assert from 'node:assert/strict';
let s = fetchReducer(initialState, { type: 'start' });
assert.equal(s.status, 'loading');
s = fetchReducer(s, { type: 'resolve', data: [1, 2] });
assert.deepEqual(s, { status: 'success', data: [1, 2], error: null });
const same = fetchReducer(s, { type: 'resolve', data: [9] });
assert.equal(same, s); // invalid transition returns the same objectWorked scenario
A file uploader showed a green “Uploaded” tick and a red error message at the same time. The component used isUploading, progress, isDone and error. When a retry succeeded, the code set isDone but forgot to clear error from the previous attempt. Another bug: a late progress event arriving after completion set isUploading back to true.
Rewritten with a reducer, each status carries only the fields it needs:
function uploadReducer(state, action) {
switch (action.type) {
case 'begin':
return { status: 'uploading', progress: 0 };
case 'progress':
if (state.status !== 'uploading') return state;
return { ...state, progress: action.value };
case 'done':
if (state.status !== 'uploading') return state;
return { status: 'done' };
case 'fail':
if (state.status !== 'uploading') return state;
return { status: 'error', message: action.message };
default:
return state;
}
}A stale progress after done is now ignored, and done cannot coexist with an error message because the done state has no message field. The impossible UI is literally unrepresentable. With TypeScript, a discriminated union on status makes the compiler enforce this too.
Common mistake
- “useReducer is for big state, useState for small.” Size is not the criterion; coupled values and transition rules are.
- Side effects in the reducer. Fetching, logging or
localStoragewrites inside a reducer run twice in Strict Mode and break purity. Do them in handlers or effects. - Mutating state and returning it.
state.items.push(x); return state;returns the same reference, so React skips the render. - Setter-style actions.
setFieldactions recreate the scattered logic in components. - Silently returning state for unknown actions. Throwing for typos (or using exhaustive TypeScript checks) catches bugs early. Ignoring valid but out-of-order actions is different and intentional.
Verify the behavior
Run the reducer tests with Node. Save the reducer and assertions in reducer.mjs (export the reducer, add an assertion that unknown actions throw) and run:
node reducer.mjs
# all reducer checks passedThen confirm the bail-out in the browser: dispatch a resolve while idle and check with React DevTools’ Profiler that the component did not re-render.
Follow-up questions
Is dispatch stable? Yes. React guarantees the same dispatch function for the component’s lifetime, so it is safe in dependency arrays and context.
How do you do async work with useReducer? In the event handler or an effect, dispatching actions as the work progresses. The reducer stays synchronous. In React 19, useActionState covers the common form-submission case; see useActionState forms.
What is the third argument to useReducer? An init function that computes the initial state lazily from the second argument, useful for expensive setup or for resetting.
Reducer plus context? A common pattern: provide state and dispatch in separate contexts so components that only dispatch do not re-render on state changes. See context scope.
Interview exercise
A shopping cart reducer handles add and remove. Users report the item count becoming negative. Here is the remove case:
case 'remove':
return {
...state,
items: state.items.map(i => i.id === action.id ? { ...i, qty: i.qty - 1 } : i),
};State the invariant that is being broken and fix it in the reducer.
Answer and reasoning
The invariant is “every line in the cart has a quantity of at least 1, and a line with zero quantity does not exist”. The remove case decrements without a floor and never deletes the line, so repeated clicks go to zero and below. The fix enforces the invariant in one place: items: state.items.flatMap(i => i.id !== action.id ? [i] : i.qty > 1 ? [{ ...i, qty: i.qty - 1 }] : []). If the id is not in the cart, flatMap still returns a new array with the same lines; a stricter version returns state unchanged so React can bail out. Fixing it in the button (disabling at zero) would only hide the bug from one caller.
Continue learning
Practise more with the React interview questions and the React MCQs. Related notes: functional updates and pure state changes, derived values without extra state and useActionState forms. Official reference: the useReducer reference and Extracting State Logic into a Reducer on react.dev.