Deep Dive into React’s useReducer — From Quiz Answers to Senior‑Level Patterns
Most React interviews won’t ask you to build an entire app.
Instead, they ask “simple” questions that are actually probing whether you really understand state and reducers:
- When should I use
useReducerinstead of multipleuseState? - Why must reducers be pure functions?
- Why can’t a reducer call an API or play an animation?
- Why do I need to clone arrays and objects instead of mutating them?
- What’s the point of the
defaultbranch in a reducer? - Why is there an optional third argument in
useReducer?
In this post we’ll start from quiz‑style answers and turn them into production‑ready patterns you can use in real projects and interviews.
1. When to choose useReducer over useState
Quiz answer recap
✅ Use
useReducerwhen the state is complex and a single user action must trigger coordinated updates in multiple parts of the state.
That’s the right mental model.
useState is perfect when you have simple, independent pieces of state:
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
But once updates start to look like this:
- One action touches several fields at once
- You start passing multiple setters around
- You’re encoding rules like “if X is true, reset Y and Z”
…you’re ready for useReducer.
Example: A game state that evolves together
type GameState = {
status: "idle" | "playing" | "won" | "lost";
points: number;
attemptsLeft: number;
};
type GameAction =
| { type: "START" }
| { type: "WIN" }
| { type: "LOSE" }
| { type: "SCORE"; payload: { points: number } };
function gameReducer(state: GameState, action: GameAction): GameState {
switch (action.type) {
case "START":
return { status: "playing", points: 0, attemptsLeft: 3 };
case "SCORE":
return {
...state,
points: state.points + action.payload.points,
};
case "WIN":
return { ...state, status: "won" };
case "LOSE":
return { ...state, status: "lost" };
default:
return state;
}
}
Here one action ("START", "WIN", "LOSE") updates multiple fields in a single, predictable place. This is exactly where useReducer shines.
Interview soundbite:
“I reach foruseReducerwhen my state has multiple properties that need to be updated together and the transitions between states are more important than the values themselves.”
2. Reducers must be pure — no side effects allowed
Quiz answer recap
❌ “A reducer can perform side effects like API calls or animations to keep things centralized.”
✅ False.
A reducer must be a pure function:
function reducer(state, action) {
// ✅ allowed: compute new state based only on inputs
return nextState;
}
Pure means:
- No API calls
- No logging that depends on time
- No
setTimeout,setInterval, orsetStateinside - No DOM manipulation
- No
async/await
Correct vs incorrect reducer
// ✅ Correct reducer
function counterReducer(state: { count: number }, action: { type: "increment" }) {
switch (action.type) {
case "increment":
return { count: state.count + 1 };
default:
return state;
}
}
// ❌ Incorrect reducer – side effect inside
function counterReducer(state, action) {
switch (action.type) {
case "increment":
fetch("/api/log"); // 🚫 side effect
return { count: state.count + 1 };
default:
return state;
}
}
Where do side effects go then?
Into useEffect in the component:
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
useEffect(() => {
if (state.count > 0) {
// ✅ side effect lives here, not in the reducer
fetch("/api/log-count", { method: "POST", body: state.count.toString() });
}
}, [state.count]);
Interview soundbite:
“Reducers calculate what the state should be. Components and hooks likeuseEffectare responsible for what should happen when the state changes.”
3. Immutability: why we don’t mutate arrays or objects in state
Quiz answer recap
✅ “React decided whether to re‑render by comparing state references. Mutating the original array doesn’t change its reference, so React doesn’t detect a change.”
React does a shallow comparison: it checks whether the reference changed, not the internal contents.
The bug: mutating state in place
const [items, setItems] = useState<number[]>([1, 2, 3]);
function addItemWrong() {
items.push(4); // 🚫 mutates existing array
setItems(items); // same reference → React may skip render
}
Even though [1, 2, 3, 4] “looks different”, the reference in memory didn’t change.
The fix: create a new reference
function addItemCorrect() {
setItems(prev => [...prev, 4]); // ✅ new array, new reference
}
This pattern is even more important with useReducer, because reducers often return updated arrays or objects.
4. LocalStorage, JSON and why Zod is your safety net
We often want to persist reducer state to localStorage, for example in a game or form wizard.
4.1 Why JSON.stringify and JSON.parse are required
Quiz answer recap
✅
localStoragecan only store strings.
JSON.stringifyconverts an object → string andJSON.parseconverts it back string → object.
const user = { name: "Cristian", level: 5 };
localStorage.setItem("user", JSON.stringify(user));
const raw = localStorage.getItem("user");
const restored = raw ? JSON.parse(raw) : null;
No encryption, no compression — just serialization.
4.2 Why you should validate with Zod
Now the subtle bug: data coming out of localStorage is untrusted.
It might be:
- Edited in DevTools
- Written by an older app version
- Corrupted JSON
- Missing fields
Quiz answer recap
✅ The main purpose of using Zod with external data is to protect the app from malformed or malicious values that could crash at runtime.
import { z } from "zod";
const GameStateSchema = z.object({
status: z.enum(["idle", "playing", "won", "lost"]),
points: z.number(),
attemptsLeft: z.number(),
});
function loadGameState(): GameState {
const raw = localStorage.getItem("game-state");
if (!raw) return { status: "idle", points: 0, attemptsLeft: 3 };
const parsed = GameStateSchema.safeParse(JSON.parse(raw));
if (!parsed.success) {
// corrupted data → fallback to safe initial state
return { status: "idle", points: 0, attemptsLeft: 3 };
}
return parsed.data;
}
Interview soundbite:
“TypeScript checks the code I write. Zod checks the data I receive at runtime — especially from places likelocalStorageor APIs.”
5. Actions and payloads: naming things like a pro
Quiz answer recap
✅ The
payloadof an action is the additional data the reducer needs to compute the next state.
Classic shape:
type AddTodoAction = {
type: "ADD_TODO";
payload: { id: string; text: string };
};
{
type: "ADD_TODO",
payload: { id: "1", text: "Learn useReducer deeply" }
}
-
type→ what happened -
payload→ with which data it happened
Reducer:
function todosReducer(state: Todo[], action: TodoAction): Todo[] {
switch (action.type) {
case "ADD_TODO":
return [...state, action.payload];
// ...
default:
return state;
}
}
Interview soundbite:
“The payload is the structured information that travels with an action so the reducer can do its job.”
6. Side effects like confetti: where do they really belong?
Imagine a word‑guessing game where you want to fire a confetti animation when the user guesses correctly.
Trick quiz:
“Since reducers centralize the logic, should we call confetti() inside the reducer when the player wins?”
Answer: absolutely not.
Quiz answer recap
✅ The correct approach is to observe the state in a component with
useEffectand trigger the animation there.
Wrong: calling confetti inside the reducer
function gameReducer(state, action) {
switch (action.type) {
case "WIN":
confetti(); // 🚫 side effect
return { ...state, status: "won" };
default:
return state;
}
}
Correct: reducer updates state, component reacts
const [state, dispatch] = useReducer(gameReducer, initialGameState);
useEffect(() => {
if (state.status === "won") {
confetti(); // 🎉 allowed here
}
}, [state.status]);
Rule of thumb:
Reducers calculate state. Components orchestrate side effects.
7. The default branch: your reducer’s safety net
Quiz answer recap
✅ The main purpose of the
defaultcase is to guarantee that the reducer always returns a valid state if it receives an unknown action.
function reducer(state: State, action: Action): State {
switch (action.type) {
case "INCREMENT":
return { count: state.count + 1 };
case "DECREMENT":
return { count: state.count - 1 };
default:
// ✅ safe fallback: return the current state
return state;
}
}
This protects you from:
- Typos in the action type
- Legacy actions still being dispatched
- New actions added elsewhere but not yet implemented
Yes, in some Redux setups you can throw an error in strict mode, but for UI reducers the safer pattern is to just return the current state.
Interview soundbite:
“Thedefaultbranch ensures the reducer is total — it always returns a usable state, even for unknown actions.”
8. Lazy initialization with getInitialState (the third argument)
Why does useReducer have a third argument?
const [state, dispatch] = useReducer(reducer, initialArg, initFunction);
Quiz answer recap
✅ It’s often better to use an initializer function because it allows you to run more complex initialization logic once — like reading from
localStorage— instead of on every render.
Example: hydrate game state from LocalStorage
type GameState = {
status: "idle" | "playing" | "won" | "lost";
points: number;
attemptsLeft: number;
};
function getInitialState(): GameState {
const raw = localStorage.getItem("game-state");
if (!raw) {
return { status: "idle", points: 0, attemptsLeft: 3 };
}
try {
const parsed = JSON.parse(raw);
// you’d normally validate with Zod here
return parsed;
} catch {
return { status: "idle", points: 0, attemptsLeft: 3 };
}
}
const [state, dispatch] = useReducer(gameReducer, null, getInitialState);
Benefits:
- Runs only once when the component mounts
- Keeps the reducer pure
- Avoids heavy work on every render
Interview soundbite:
“I use the third argument ofuseReducerfor lazy initialization — especially when hydrating fromlocalStorageor doing expensive setup.”
9. Bonus: owning your UI primitives with shadcn/ui
A quick tangent from the quiz material, but highly relevant for real projects.
Libraries like Material‑UI or Bootstrap live inside node_modules. You consume their components as black boxes:
import { Button } from "@mui/material";
shadcn/ui takes a different approach.
Quiz answer recap
✅ It doesn’t install as a typical dependency. Instead, it copies component source files into your project, giving you full control.
That means:
- You own the TSX files and Tailwind classes
- You can customize, refactor, or even rewrite them
- They play extremely well with reducer‑based state machines and domain‑specific UIs
Their motto says it all:
“It’s not a component library. It’s a collection of re‑usable components you copy into your apps.”
In practice, it fits nicely with the mindset of owning your state machines (useReducer) and owning your UI primitives (shadcn/ui) instead of outsourcing everything to opaque libraries.
10. useReducer interview & code review checklist
Here’s a quick mental checklist you can run before your next interview, quiz, or PR review:
When to use useReducer
- State has multiple related fields
- Single actions need coordinated updates
- You’re modeling transitions (“from idle to playing to won”)
- You want Redux‑style predictability without pulling Redux in
Reducer rules
- Must be pure: no side effects, no async, no DOM
- Always return a valid state (thanks to the
defaultcase) - Never mutate arrays/objects — always return new references
Integration patterns
- Side effects (APIs, confetti, logs) live in
useEffect, reacting to state changes -
localStorageintegration usesJSON.stringify/JSON.parse - Untrusted data is validated with Zod before becoming state
- Complex initialization uses the third
useReducerargument
Final thoughts
Most “trick” questions about useReducer are not really about syntax — they’re about mental models:
- What does pure actually mean in a reducer?
- Why does React care about references, not deep equality?
- Who is responsible for side effects?
- How do we keep state transitions predictable and safe?
If you can answer those questions — and support them with patterns like the ones above — you’re already thinking at a senior React level.
Feel free to reuse this article in your own notes, internal workshops, or interview prep.
Happy reducing. 🧠⚙️

Top comments (0)