DEV Community

Cristian Sifuentes
Cristian Sifuentes

Posted on

Deep Dive into React’s useReducer — From Quiz Answers to Senior‑Level Patterns

Deep Dive into React’s  raw `useReducer` endraw  — From Quiz Answers to Senior‑Level Patterns

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 useReducer instead of multiple useState?
  • 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 default branch 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 useReducer when 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);
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

Here one action ("START", "WIN", "LOSE") updates multiple fields in a single, predictable place. This is exactly where useReducer shines.

Interview soundbite:

“I reach for useReducer when 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;
}
Enter fullscreen mode Exit fullscreen mode

Pure means:

  • No API calls
  • No logging that depends on time
  • No setTimeout, setInterval, or setState inside
  • 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;
  }
}
Enter fullscreen mode Exit fullscreen mode
// ❌ 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

Interview soundbite:

“Reducers calculate what the state should be. Components and hooks like useEffect are 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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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

localStorage can only store strings.

JSON.stringify converts an object → string and JSON.parse converts 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;
Enter fullscreen mode Exit fullscreen mode

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(),
});
Enter fullscreen mode Exit fullscreen mode
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;
}
Enter fullscreen mode Exit fullscreen mode

Interview soundbite:

“TypeScript checks the code I write. Zod checks the data I receive at runtime — especially from places like localStorage or APIs.”


5. Actions and payloads: naming things like a pro

Quiz answer recap

✅ The payload of 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 };
};
Enter fullscreen mode Exit fullscreen mode
{
  type: "ADD_TODO",
  payload: { id: "1", text: "Learn useReducer deeply" }
}
Enter fullscreen mode Exit fullscreen mode
  • typewhat happened
  • payloadwith 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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 useEffect and 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

Correct: reducer updates state, component reacts

const [state, dispatch] = useReducer(gameReducer, initialGameState);

useEffect(() => {
  if (state.status === "won") {
    confetti(); // 🎉 allowed here
  }
}, [state.status]);
Enter fullscreen mode Exit fullscreen mode

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 default case 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

“The default branch 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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 of useReducer for lazy initialization — especially when hydrating from localStorage or 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";
Enter fullscreen mode Exit fullscreen mode

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 default case)
  • Never mutate arrays/objects — always return new references

Integration patterns

  • Side effects (APIs, confetti, logs) live in useEffect, reacting to state changes
  • localStorage integration uses JSON.stringify / JSON.parse
  • Untrusted data is validated with Zod before becoming state
  • Complex initialization uses the third useReducer argument

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)