React Interview Prep
Hooks

useReducer

Complex State Logic

LinkedIn Hook

You reach for useState every time. It works for a counter, a toggle, a form field.

But then your state grows. You have five fields that depend on each other. One action needs to update three things at once. Your event handlers are full of scattered setState calls and the logic is impossible to follow.

This is where most developers hit a wall — and where interviewers start paying attention.

useReducer is the hook that separates developers who build toy apps from developers who build real ones. It gives you the reducer pattern: state + action = new state. One function, all your logic, completely predictable.

In this lesson, I break down when useState isn't enough, how the reducer pattern works, why dispatch is more powerful than multiple setters, how to decide between useReducer and useState, and how combining useReducer with useContext gives you a lightweight state management solution — no external library needed.

If your state logic has ever felt like spaghetti, this is the lesson that straightens it out.

Read the full lesson → [link]

#React #JavaScript #InterviewPrep #Frontend #CodingInterview #ReactHooks #useReducer #100DaysOfCode


useReducer thumbnail


What You'll Learn

  • When useState isn't enough — the signs that you need useReducer
  • The reducer pattern — how state + action produces new state
  • How dispatch works and why it's more stable than setter functions
  • useReducer vs useState — a clear decision framework
  • Combining useReducer with useContext for scalable state management

The Concept — The Reducer Pattern

Analogy: The Bank Teller

Imagine you walk into a bank. You don't reach behind the counter and change your account balance yourself — that would be chaos. Instead, you fill out a slip: "deposit $500" or "withdraw $200." You hand the slip to the teller. The teller looks at your current balance, reads the slip, applies the rules, and gives you a new balance.

State is your account balance. Action is the slip you fill out — it describes what you want to happen. The reducer is the teller — it takes the current state and the action, applies the logic, and returns the new state. Dispatch is the act of handing the slip to the teller.

You never touch the balance directly. You always go through the teller. That is the reducer pattern: predictable, centralized, and traceable.

Now compare this to useState. With useState, every event handler reaches behind the counter and changes the balance directly. When you have two or three values, that's fine. When you have ten values and five different actions that each affect multiple values — direct access becomes a nightmare. You need a teller.


When useState Isn't Enough

You should reach for useReducer when you notice any of these patterns:

  1. Multiple state values that change together — one user action updates three pieces of state at once
  2. The next state depends on the previous state in complex ways — not just prev + 1, but "if status is loading and the action is success, update data, clear error, and set status to idle"
  3. State transitions follow strict rules — a form can go from "idle" to "submitting" to "success" or "error," but never from "success" directly to "submitting"
  4. You want testable state logic — reducers are pure functions you can unit test without rendering any components

Code Example 1: useState vs useReducer — A Simple Comparison

import { useState, useReducer } from "react";

// ---- WITH useState ----
// Works fine for simple cases, but watch how the logic scatters
function CounterWithState() {
  const [count, setCount] = useState(0);

  // Each handler contains its own logic
  function increment() { setCount(prev => prev + 1); }
  function decrement() { setCount(prev => prev - 1); }
  function reset() { setCount(0); }

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
      <button onClick={reset}>Reset</button>
    </div>
  );
}

// ---- WITH useReducer ----
// All logic lives in one place — the reducer function
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:
      throw new Error("Unknown action: " + action.type);
  }
}

function CounterWithReducer() {
  const [state, dispatch] = useReducer(counterReducer, { count: 0 });

  // Handlers just describe WHAT happened — no logic here
  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
      <button onClick={() => dispatch({ type: "reset" })}>Reset</button>
    </div>
  );
}

// Output (both versions behave identically):
// Count: 0
// Click "+" → Count: 1
// Click "+" → Count: 2
// Click "-" → Count: 1
// Click "Reset" → Count: 0

For a counter, both approaches work. The reducer version is more verbose — and that is the point. The benefit only shows up when state gets complex.

useReducer visual 1


Code Example 2: Complex Form State — Where useReducer Shines

import { useReducer } from "react";

// The initial state for a form with loading, error, and validation
const initialState = {
  values: { username: "", email: "", password: "" },
  errors: {},
  status: "idle", // "idle" | "submitting" | "success" | "error"
  touched: {}
};

// All form logic centralized in one function
function formReducer(state, action) {
  switch (action.type) {
    case "field_change":
      // Update one field's value and mark it as touched
      return {
        ...state,
        values: { ...state.values, [action.field]: action.value },
        touched: { ...state.touched, [action.field]: true },
        // Clear the error for this field when user starts typing
        errors: { ...state.errors, [action.field]: undefined }
      };

    case "submit":
      // Only allow submission from idle or error status
      if (state.status === "submitting") return state;
      return { ...state, status: "submitting", errors: {} };

    case "success":
      return { ...initialState, status: "success" };

    case "error":
      // Server returned validation errors
      return { ...state, status: "error", errors: action.errors };

    case "reset":
      return initialState;

    default:
      throw new Error("Unknown action: " + action.type);
  }
}

function SignupForm() {
  const [state, dispatch] = useReducer(formReducer, initialState);

  async function handleSubmit(e) {
    e.preventDefault();
    dispatch({ type: "submit" });

    try {
      await api.signup(state.values);
      dispatch({ type: "success" });
    } catch (err) {
      dispatch({ type: "error", errors: err.fieldErrors });
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={state.values.username}
        onChange={e =>
          dispatch({ type: "field_change", field: "username", value: e.target.value })
        }
      />
      {state.errors.username && <span>{state.errors.username}</span>}

      <input
        value={state.values.email}
        onChange={e =>
          dispatch({ type: "field_change", field: "email", value: e.target.value })
        }
      />
      {state.errors.email && <span>{state.errors.email}</span>}

      <button disabled={state.status === "submitting"}>
        {state.status === "submitting" ? "Submitting..." : "Sign Up"}
      </button>

      {state.status === "success" && <p>Account created!</p>}
    </form>
  );
}

// Initial render:
//   [username input] [email input] [Sign Up button]
//
// After typing "alice" in username:
//   values.username = "alice", touched.username = true
//
// After clicking Sign Up:
//   Button shows "Submitting..." and is disabled
//
// On success:
//   Form resets, shows "Account created!"
//
// On error:
//   Error messages appear under the relevant fields

Notice how the formReducer makes every possible state transition explicit. You can read the reducer top to bottom and understand every way the form can change. Try doing that with six separate useState calls and scattered setState logic across multiple handlers.

useReducer visual 2


Code Example 3: useReducer + useContext — Lightweight State Management

import { createContext, useContext, useReducer } from "react";

// Define the reducer and initial state
const initialState = { items: [], total: 0 };

function cartReducer(state, action) {
  switch (action.type) {
    case "add_item": {
      const existing = state.items.find(i => i.id === action.item.id);
      // If item already exists, increase its quantity
      if (existing) {
        const updatedItems = state.items.map(i =>
          i.id === action.item.id ? { ...i, qty: i.qty + 1 } : i
        );
        return {
          items: updatedItems,
          total: state.total + action.item.price
        };
      }
      // Otherwise add a new item with qty 1
      return {
        items: [...state.items, { ...action.item, qty: 1 }],
        total: state.total + action.item.price
      };
    }

    case "remove_item": {
      const item = state.items.find(i => i.id === action.id);
      if (!item) return state;
      return {
        items: state.items.filter(i => i.id !== action.id),
        total: state.total - item.price * item.qty
      };
    }

    case "clear":
      return initialState;

    default:
      throw new Error("Unknown action: " + action.type);
  }
}

// Create two contexts: one for state, one for dispatch
const CartStateContext = createContext(null);
const CartDispatchContext = createContext(null);

// Provider component wraps the app
function CartProvider({ children }) {
  const [state, dispatch] = useReducer(cartReducer, initialState);

  return (
    <CartStateContext.Provider value={state}>
      <CartDispatchContext.Provider value={dispatch}>
        {children}
      </CartDispatchContext.Provider>
    </CartStateContext.Provider>
  );
}

// Custom hooks for clean access
function useCartState() {
  return useContext(CartStateContext);
}
function useCartDispatch() {
  return useContext(CartDispatchContext);
}

// Any component in the tree can read state
function CartTotal() {
  const { total } = useCartState();
  return <p>Total: ${total}</p>;
}

// Any component in the tree can dispatch actions
function AddButton({ item }) {
  const dispatch = useCartDispatch();
  return (
    <button onClick={() => dispatch({ type: "add_item", item })}>
      Add {item.name}
    </button>
  );
}

// Usage at the app level
function App() {
  return (
    <CartProvider>
      <AddButton item={{ id: 1, name: "React Book", price: 29 }} />
      <AddButton item={{ id: 2, name: "Hook Cheatsheet", price: 9 }} />
      <CartTotal />
    </CartProvider>
  );
}

// Initial render:
//   [Add React Book] [Add Hook Cheatsheet]
//   Total: $0
//
// After clicking "Add React Book":
//   Total: $29
//
// After clicking "Add React Book" again:
//   Total: $58 (qty is now 2)
//
// After clicking "Add Hook Cheatsheet":
//   Total: $67

Splitting state and dispatch into separate contexts is a performance optimization. Components that only dispatch actions (like buttons) don't re-render when the state changes, because they only consume the dispatch context, which never changes.

useReducer visual 3


useReducer vs useState — The Decision Framework

+----------------------------------+----------------------------------+
| Use useState when...             | Use useReducer when...           |
+----------------------------------+----------------------------------+
| State is a single primitive      | State is an object with multiple |
| (number, string, boolean)        | related fields                   |
+----------------------------------+----------------------------------+
| State transitions are simple     | Next state depends on previous   |
| (set to X, toggle, increment)    | state in complex ways            |
+----------------------------------+----------------------------------+
| One or two state variables       | Multiple state values change     |
|                                  | together from one action         |
+----------------------------------+----------------------------------+
| Event handlers are               | You want state logic separated   |
| straightforward                  | from the component (testability) |
+----------------------------------+----------------------------------+
| Quick prototyping                | State has strict transitions     |
|                                  | (idle → loading → success/error) |
+----------------------------------+----------------------------------+

A practical shortcut: if you find yourself writing more than two setState calls inside a single event handler, that is a signal to consider useReducer.


Common Mistakes

Mistake 1: Mutating state inside the reducer

// WRONG — mutates the existing state object
function reducer(state, action) {
  if (action.type === "add_item") {
    state.items.push(action.item);  // Mutating the array directly
    state.total += action.item.price;
    return state;  // Same reference — React won't re-render
  }
}

// RIGHT — always return a new object
function reducer(state, action) {
  if (action.type === "add_item") {
    return {
      items: [...state.items, action.item],  // New array
      total: state.total + action.item.price  // New value
    };
  }
}

The same immutability rules from useState apply to useReducer. The reducer must return a new state object, never mutate the existing one.

Mistake 2: Putting side effects inside the reducer

// WRONG — reducers must be pure functions, no side effects
function reducer(state, action) {
  if (action.type === "submit") {
    fetch("/api/submit", { method: "POST", body: JSON.stringify(state) });
    // Side effect inside reducer — unpredictable, untestable
    return { ...state, status: "submitting" };
  }
}

// RIGHT — side effects go in the component or useEffect
function MyComponent() {
  const [state, dispatch] = useReducer(reducer, initialState);

  async function handleSubmit() {
    dispatch({ type: "submit" });                // Pure state update
    const result = await fetch("/api/submit");   // Side effect in the component
    dispatch({ type: "success", data: result }); // Another pure state update
  }
}

Reducers are pure functions: same inputs, same output, no side effects. API calls, localStorage writes, and logging belong outside the reducer.

Mistake 3: Overusing useReducer for simple state

// OVERKILL — useReducer for a simple boolean toggle
function reducer(state, action) {
  switch (action.type) {
    case "toggle": return { isOpen: !state.isOpen };
    default: return state;
  }
}
function Modal() {
  const [state, dispatch] = useReducer(reducer, { isOpen: false });
  return <button onClick={() => dispatch({ type: "toggle" })}>Toggle</button>;
}

// JUST USE useState — simpler, clearer, less code
function Modal() {
  const [isOpen, setIsOpen] = useState(false);
  return <button onClick={() => setIsOpen(prev => !prev)}>Toggle</button>;
}

Not every piece of state needs a reducer. If your state logic is simple, useState is the right choice. useReducer pays off when complexity grows.


Interview Questions

Q: When would you choose useReducer over useState?

When state is complex (multiple related values), when one action needs to update multiple fields, when the next state depends on the previous state in non-trivial ways, or when you want to centralize and test your state logic independently. A practical signal is having multiple setState calls in a single event handler.

Q: What is the reducer pattern and how does useReducer implement it?

The reducer pattern is: (currentState, action) => newState. A reducer is a pure function that takes the current state and an action object, then returns a new state based on the action type. useReducer(reducer, initialState) returns [state, dispatch]. You call dispatch(action) to send an action to the reducer, which computes and returns the new state. React then re-renders with the updated state.

Q: Why is dispatch stable across renders, and why does that matter?

React guarantees that the dispatch function returned by useReducer has a stable identity — it doesn't change between renders. This means you can pass dispatch to child components or include it in dependency arrays without causing unnecessary re-renders or infinite effect loops. With useState, setter functions are also stable, but when you need to pass complex update logic to children, a single dispatch is cleaner than passing multiple setters.

Q: How would you combine useReducer with useContext for app-wide state?

Create a reducer and initial state, then wrap your component tree with a context provider that calls useReducer and passes state and dispatch through context. For better performance, use two separate contexts — one for state and one for dispatch — so components that only dispatch actions don't re-render when state changes. Create custom hooks like useAppState() and useAppDispatch() to access each context cleanly.

Q: Can you put async logic inside a reducer? Why or why not?

No. Reducers must be pure functions — same inputs, same output, no side effects. Async operations like API calls, localStorage writes, or timers are side effects. They belong in event handlers or useEffect. The pattern is: dispatch an action to set loading state, perform the async operation in the component, then dispatch another action with the result (success or error).


Quick Reference — Cheat Sheet

+-----------------------------------+-------------------------------------------+
| Concept                           | Key Point                                 |
+-----------------------------------+-------------------------------------------+
| useReducer syntax                 | const [state, dispatch] =                 |
|                                   | useReducer(reducer, initialState)         |
+-----------------------------------+-------------------------------------------+
| Reducer function                  | (state, action) => newState               |
|                                   | Must be pure. No side effects.            |
+-----------------------------------+-------------------------------------------+
| Action object                     | { type: "action_name", ...payload }       |
|                                   | type is a convention, not enforced.       |
+-----------------------------------+-------------------------------------------+
| Dispatch                          | dispatch({ type: "increment" })           |
|                                   | Stable identity — safe for deps arrays.   |
+-----------------------------------+-------------------------------------------+
| When to use useReducer            | Complex state, multiple fields change     |
|                                   | together, strict state transitions,       |
|                                   | testable logic.                           |
+-----------------------------------+-------------------------------------------+
| When to stick with useState       | Simple state, single primitives,          |
|                                   | independent values, quick prototyping.    |
+-----------------------------------+-------------------------------------------+
| useReducer + useContext            | Split into two contexts (state +          |
|                                   | dispatch) for performance. Components     |
|                                   | that only dispatch won't re-render on     |
|                                   | state changes.                            |
+-----------------------------------+-------------------------------------------+
| Immutability in reducers          | Same rules as useState — never mutate,    |
|                                   | always return a new object/array.         |
+-----------------------------------+-------------------------------------------+

RULE: Reducers are pure functions — no API calls, no side effects.
RULE: One action, one state transition — keeps logic traceable.
RULE: If you have 2+ setState calls in one handler → consider useReducer.

Previous: Lesson 3.5 — useContext — Avoiding Prop Drilling → Next: Lesson 3.7 — Custom Hooks — Reusable Logic →


This is Lesson 3.6 of the React Interview Prep Course — 10 chapters, 42 lessons.

On this page