React Interview Prep
State Management

Context API + useReducer Pattern

Building Redux-Lite

LinkedIn Hook

Everyone says "just use Context + useReducer" as an alternative to Redux.

Then they build it, and their entire app re-renders every time one piece of state changes.

The pattern works. But only if you understand the re-render trap that Context creates by default. One Provider wrapping your app with a big state object means every consumer re-renders on every dispatch — even if the value they care about did not change.

The fix is not complicated, but most tutorials skip it: split your contexts. One for state, one for dispatch. Better yet, split by domain — auth context, theme context, cart context. Each consumer only subscribes to what it needs.

In interviews, they will ask you to build a mini state management system with Context + useReducer. They want to see if you know the provider pattern, if you can write a proper reducer, and most importantly — if you know why this approach breaks down at scale and when you should reach for Redux or Zustand instead.

In this lesson, I break down the full pattern: creating the context, writing the reducer, building the provider component, splitting contexts to avoid unnecessary re-renders, and the exact limitations that make this approach unsuitable for large applications.

If you have ever been told "Context replaces Redux" and felt something was off — you were right.

Read the full lesson -> [link]

#React #JavaScript #InterviewPrep #Frontend #StateManagement #ContextAPI #useReducer #CodingInterview #100DaysOfCode


Context API + useReducer Pattern thumbnail


What You'll Learn

  • How to combine Context API with useReducer to build a "Redux-lite" state management system
  • The provider pattern and how to structure context for clean consumption
  • Why a single context causes unnecessary re-renders and how splitting contexts fixes it
  • The exact limitations of this pattern at scale and when to reach for external libraries

The Concept — Redux-Lite with Built-In Tools

Analogy: The Office Intercom System

Imagine a small office with 10 employees. The manager (Provider) has an intercom system that broadcasts announcements to every room. When something changes — a new policy, a schedule update, a lunch order — the manager announces it on the intercom, and every room hears it.

This works fine in a small office. But here is the problem: when the manager announces "lunch order changed," the accounting department stops what they are doing to listen, even though they only care about policy updates. The engineering team pauses too, even though they only care about schedule changes. Every announcement interrupts everyone.

That is exactly what happens with a single React context. When any part of the state changes, every component consuming that context re-renders — even if the specific value they use did not change.

The fix is the same one a real office uses: separate intercom channels. Policy announcements go on channel 1. Schedule updates go on channel 2. Lunch orders go on channel 3. Each department only tunes into the channels they care about.

In React, this means splitting your contexts — one for auth, one for theme, one for cart. Each consumer subscribes only to the data it needs.

useReducer is the manager's decision-making process. Instead of the manager making random changes, every request comes in as a formal memo (action): "ADD_ITEM", "REMOVE_ITEM", "UPDATE_QUANTITY". The manager follows a strict rulebook (reducer) to decide exactly how each memo changes the office state. Predictable, traceable, testable.


Building the Pattern Step by Step

The Context + useReducer pattern has four parts:

  1. Define the reducer — a pure function that takes state + action and returns new state
  2. Create the context — a React context to hold the state and dispatch
  3. Build the provider — a component that wraps useReducer and passes values through context
  4. Consume in components — useContext to read state and dispatch actions

Code Example 1: Basic Todo App with Context + useReducer

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

// Step 1: Define the reducer — pure function, no side effects
function todoReducer(state, action) {
  switch (action.type) {
    case "ADD_TODO":
      return {
        ...state,
        todos: [
          ...state.todos,
          { id: Date.now(), text: action.payload, completed: false },
        ],
      };
    case "TOGGLE_TODO":
      return {
        ...state,
        todos: state.todos.map((todo) =>
          todo.id === action.payload
            ? { ...todo, completed: !todo.completed }
            : todo
        ),
      };
    case "DELETE_TODO":
      return {
        ...state,
        todos: state.todos.filter((todo) => todo.id !== action.payload),
      };
    default:
      // Throw on unknown actions — catches typos early
      throw new Error(`Unknown action type: ${action.type}`);
  }
}

const initialState = { todos: [] };

// Step 2: Create the context
const TodoContext = createContext(null);

// Step 3: Build the provider component
function TodoProvider({ children }) {
  const [state, dispatch] = useReducer(todoReducer, initialState);

  // Both state and dispatch go into the same context value
  return (
    <TodoContext.Provider value={{ state, dispatch }}>
      {children}
    </TodoContext.Provider>
  );
}

// Custom hook for consuming — cleaner than raw useContext everywhere
function useTodo() {
  const context = useContext(TodoContext);
  if (context === null) {
    throw new Error("useTodo must be used within a TodoProvider");
  }
  return context;
}

// Step 4: Consume in components
function AddTodo() {
  const { dispatch } = useTodo();

  function handleSubmit(e) {
    e.preventDefault();
    const text = e.target.elements.todo.value.trim();
    if (text) {
      dispatch({ type: "ADD_TODO", payload: text });
      e.target.reset();
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="todo" placeholder="Add a todo..." />
      <button type="submit">Add</button>
    </form>
  );
}

function TodoList() {
  const { state, dispatch } = useTodo();

  return (
    <ul>
      {state.todos.map((todo) => (
        <li key={todo.id}>
          <span
            onClick={() => dispatch({ type: "TOGGLE_TODO", payload: todo.id })}
            style={{
              textDecoration: todo.completed ? "line-through" : "none",
              cursor: "pointer",
            }}
          >
            {todo.text}
          </span>
          <button
            onClick={() => dispatch({ type: "DELETE_TODO", payload: todo.id })}
          >
            Delete
          </button>
        </li>
      ))}
    </ul>
  );
}

// Usage — wrap the app (or relevant subtree) in the provider
function App() {
  return (
    <TodoProvider>
      <h1>My Todos</h1>
      <AddTodo />
      <TodoList />
    </TodoProvider>
  );
}

// Adding "Buy milk":
// Output: List shows "Buy milk" with no strikethrough
// Clicking "Buy milk":
// Output: "Buy milk" gets strikethrough (completed: true)
// Clicking Delete:
// Output: "Buy milk" removed from list

Key point: The custom hook useTodo is not just a convenience — it enforces that consumers are inside a Provider and gives you a single place to add type safety or validation.


The Re-Render Problem — Why One Context Is Not Enough

Here is where most developers get burned. When you put both state and dispatch into a single context, every component that uses dispatch re-renders when state changes — even if that component never reads state.

Code Example 2: Demonstrating the Re-Render Trap

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

function counterReducer(state, action) {
  switch (action.type) {
    case "INCREMENT":
      return { ...state, count: state.count + 1 };
    case "SET_THEME":
      return { ...state, theme: action.payload };
    default:
      return state;
  }
}

const AppContext = createContext(null);

function AppProvider({ children }) {
  const [state, dispatch] = useReducer(counterReducer, {
    count: 0,
    theme: "light",
  });

  // PROBLEM: This value object is recreated every render
  // Every consumer re-renders on ANY state change
  return (
    <AppContext.Provider value={{ state, dispatch }}>
      {children}
    </AppContext.Provider>
  );
}

// This component only uses dispatch — it never reads state
// But it STILL re-renders every time count or theme changes
function IncrementButton() {
  const { dispatch } = useContext(AppContext);
  const renderCount = useRef(0);
  renderCount.current += 1;

  console.log(`IncrementButton rendered ${renderCount.current} times`);
  // Output after 5 clicks: "IncrementButton rendered 6 times"
  // It re-renders every time even though it only uses dispatch

  return (
    <button onClick={() => dispatch({ type: "INCREMENT" })}>
      + Increment
    </button>
  );
}

// This component only reads count — it does not care about theme
// But it STILL re-renders when theme changes
function CountDisplay() {
  const { state } = useContext(AppContext);
  const renderCount = useRef(0);
  renderCount.current += 1;

  console.log(`CountDisplay rendered ${renderCount.current} times`);
  // Output after theme change: "CountDisplay rendered 2 times"
  // It re-renders even though count did not change

  return <p>Count: {state.count}</p>;
}

function App() {
  return (
    <AppProvider>
      <CountDisplay />
      <IncrementButton />
    </AppProvider>
  );
}

Why this happens: React context has no selector mechanism. When the Provider's value changes (and it changes on every render because { state, dispatch } creates a new object), every component calling useContext for that context re-renders. There is no built-in way to subscribe to just a slice of context.

Context API + useReducer Pattern visual 1


The Fix — Splitting Contexts

The solution is straightforward: separate state and dispatch into different contexts, or better yet, split by domain.

Code Example 3: Split State and Dispatch Contexts

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

function counterReducer(state, action) {
  switch (action.type) {
    case "INCREMENT":
      return { ...state, count: state.count + 1 };
    case "SET_THEME":
      return { ...state, theme: action.payload };
    default:
      return state;
  }
}

// Two separate contexts — one for reading, one for writing
const StateContext = createContext(null);
const DispatchContext = createContext(null);

function AppProvider({ children }) {
  const [state, dispatch] = useReducer(counterReducer, {
    count: 0,
    theme: "light",
  });

  // dispatch is stable — useReducer guarantees the same reference
  // So DispatchContext.Provider never triggers re-renders from value change
  return (
    <DispatchContext.Provider value={dispatch}>
      <StateContext.Provider value={state}>
        {children}
      </StateContext.Provider>
    </DispatchContext.Provider>
  );
}

// Custom hooks for clean consumption
function useAppState() {
  const context = useContext(StateContext);
  if (context === null) {
    throw new Error("useAppState must be used within AppProvider");
  }
  return context;
}

function useAppDispatch() {
  const context = useContext(DispatchContext);
  if (context === null) {
    throw new Error("useAppDispatch must be used within AppProvider");
  }
  return context;
}

// NOW: This component only subscribes to DispatchContext
// dispatch reference is stable, so this NEVER re-renders from state changes
function IncrementButton() {
  const dispatch = useAppDispatch();
  const renderCount = useRef(0);
  renderCount.current += 1;

  console.log(`IncrementButton rendered ${renderCount.current} times`);
  // Output after 5 clicks: "IncrementButton rendered 1 times"
  // It renders once and never again — dispatch is stable

  return (
    <button onClick={() => dispatch({ type: "INCREMENT" })}>
      + Increment
    </button>
  );
}

// This component subscribes to StateContext
// It still re-renders on theme changes (because state object changes)
// For further optimization, split state into domain-specific contexts
function CountDisplay() {
  const state = useAppState();
  const renderCount = useRef(0);
  renderCount.current += 1;

  console.log(`CountDisplay rendered ${renderCount.current} times`);

  return <p>Count: {state.count}</p>;
}

function App() {
  return (
    <AppProvider>
      <CountDisplay />
      <IncrementButton />
    </AppProvider>
  );
}

// After 5 clicks on Increment:
// IncrementButton: rendered 1 time (never re-renders — dispatch is stable)
// CountDisplay: rendered 6 times (re-renders on state changes — expected)

Interview takeaway: The key insight is that useReducer guarantees dispatch has a stable reference — it never changes between renders. By putting dispatch in its own context, components that only write (dispatch actions) never re-render from state changes.

Going Further — Split by Domain

For even better performance, split contexts by domain so that a theme change does not re-render the counter display:

// Separate contexts per domain
const CountContext = createContext(null);
const ThemeContext = createContext(null);
const DispatchContext = createContext(null);

function AppProvider({ children }) {
  const [state, dispatch] = useReducer(counterReducer, {
    count: 0,
    theme: "light",
  });

  return (
    <DispatchContext.Provider value={dispatch}>
      <CountContext.Provider value={state.count}>
        <ThemeContext.Provider value={state.theme}>
          {children}
        </ThemeContext.Provider>
      </CountContext.Provider>
    </DispatchContext.Provider>
  );
}

// Now CountDisplay only re-renders when count changes
// ThemeToggle only re-renders when theme changes
// IncrementButton never re-renders

Context API + useReducer Pattern visual 2


Limitations at Scale

The Context + useReducer pattern works well for small to medium apps. But it has real limitations that interviewers expect you to know:

1. No selectors. Redux lets you subscribe to a slice of state with useSelector(state => state.count). Context has no equivalent — you get the entire context value or nothing. Splitting contexts is the only workaround, and it does not scale when you have 20 domains.

2. Provider nesting hell. Every split context adds another Provider wrapper. Five domains mean five nested Providers. This is ugly, harder to debug, and tedious to maintain.

// This is what 5 split contexts looks like — "Provider hell"
function AppProviders({ children }) {
  return (
    <AuthProvider>
      <ThemeProvider>
        <CartProvider>
          <NotificationProvider>
            <ModalProvider>
              {children}
            </ModalProvider>
          </NotificationProvider>
        </CartProvider>
      </ThemeProvider>
    </AuthProvider>
  );
}

3. No middleware. Redux has middleware for logging, async operations, and side effects. Context + useReducer has no middleware pipeline. You handle async logic manually (usually in the component before dispatching).

4. No devtools. Redux DevTools let you time-travel, inspect every action, and replay state changes. Context provides no debugging tools beyond React DevTools, which only shows the current value.

5. Performance ceiling. Even with split contexts, React context re-renders all consumers when the value changes. In apps with hundreds of consumers, this creates performance bottlenecks that Redux (with its selector-based subscription model) avoids entirely.

When to Use Context + useReducer

Use CaseRight Tool
Theme (light/dark)Context
Auth state (logged in user)Context
Language/localeContext
Shopping cart (small app)Context + useReducer
Complex form with many fieldsuseReducer (local, no context)
Frequently updating global stateRedux / Zustand
Large app with 10+ state domainsRedux / Zustand
Need middleware or devtoolsRedux

Common Mistakes

Mistake 1: Forgetting that context value creates a new object every render

function BadProvider({ children }) {
  const [state, dispatch] = useReducer(reducer, initialState);

  // BUG: This creates a new object on every render
  // Even if state and dispatch haven't changed, the object reference is new
  // Every consumer re-renders on every parent render
  return (
    <MyContext.Provider value={{ state, dispatch }}>
      {children}
    </MyContext.Provider>
  );
}

function BetterProvider({ children }) {
  const [state, dispatch] = useReducer(reducer, initialState);

  // FIX: Split into separate contexts
  // dispatch is already stable (useReducer guarantees this)
  // state context only triggers re-renders when state actually changes
  return (
    <DispatchContext.Provider value={dispatch}>
      <StateContext.Provider value={state}>
        {children}
      </StateContext.Provider>
    </DispatchContext.Provider>
  );
}

Mistake 2: Using context for frequently changing values

// BAD: Mouse position updates 60 times per second
// Every consumer re-renders 60 times per second
function MouseProvider({ children }) {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  useEffect(() => {
    function handleMove(e) {
      setPosition({ x: e.clientX, y: e.clientY });
    }
    window.addEventListener("mousemove", handleMove);
    return () => window.removeEventListener("mousemove", handleMove);
  }, []);

  return (
    <MouseContext.Provider value={position}>
      {children}
    </MouseContext.Provider>
  );
}
// Every component consuming MouseContext re-renders on every mouse move.
// Use a ref + subscription pattern, Zustand, or a dedicated library instead.

Mistake 3: Not guarding against usage outside a Provider

// BAD: Returns undefined silently — leads to confusing errors later
function useTodo() {
  return useContext(TodoContext);
  // If component is outside TodoProvider, this returns undefined
  // Then state.todos throws "Cannot read property 'todos' of undefined"
}

// GOOD: Fail fast with a clear error message
function useTodo() {
  const context = useContext(TodoContext);
  if (context === null) {
    throw new Error(
      "useTodo must be used within a <TodoProvider>. " +
      "Wrap your component tree with <TodoProvider>."
    );
  }
  return context;
}
// The error message tells the developer exactly what to do to fix it.

Interview Questions

Q: How do you combine Context API with useReducer to manage global state?

Create a context, initialize useReducer in a Provider component, and pass both state and dispatch through context. Consumer components use useContext to read state and dispatch actions. This gives you a centralized, predictable state management system without installing any external library. For better performance, split state and dispatch into separate contexts since dispatch has a stable reference.

Q: Why does a single context cause unnecessary re-renders, and how do you fix it?

Q: What is the difference between this pattern and Redux?

Redux provides selectors for subscribing to state slices, middleware for side effects and logging, devtools for time-travel debugging, and a battle-tested ecosystem. Context + useReducer has none of these. Redux also uses a subscription model where components only re-render when their selected slice changes, while context re-renders all consumers on any value change. Context + useReducer is appropriate for simple, infrequently changing state. Redux (or Zustand) is better for complex, frequently updating state.

Q: When would you choose Context + useReducer over Redux?

When the app is small, the state is simple (theme, auth, locale), updates are infrequent, and you want zero external dependencies. If the state is complex, updates are frequent, you need middleware or devtools, or you have many state domains, Redux or a modern alternative like Zustand is the better choice. The decision point is usually around 3-5 state domains — beyond that, Provider nesting and lack of selectors make context cumbersome.

Q: What does "dispatch is stable" mean, and why does it matter for performance?

useReducer guarantees that the dispatch function has the same reference across renders — it is never recreated. This means if you put dispatch in its own context, components that consume only dispatch never re-render due to state changes. This is a critical optimization because components that only fire actions (buttons, forms) do not need to know about the current state. Separating dispatch into its own context lets these components avoid unnecessary re-renders entirely.


Quick Reference — Cheat Sheet

CONTEXT + useREDUCER PATTERN
=============================

Building blocks:
  1. createContext(null)        — Create the context
  2. useReducer(reducer, init)  — Create state + dispatch
  3. <Context.Provider value>   — Wrap tree in provider
  4. useContext(Context)         — Consume in components
  5. Custom hook (useTodo)      — Clean API + error guard

Reducer rules:
  - Pure function: (state, action) => newState
  - No side effects, no API calls, no mutations
  - Always return new state object (immutable)
  - Throw on unknown action types (catches typos)

Re-render optimization:
  PROBLEM: Single context → all consumers re-render on any change
  FIX 1:   Split state and dispatch into separate contexts
  FIX 2:   Split state by domain (auth, theme, cart)
  KEY:     dispatch is stable — its context never triggers re-renders

+------------------------+-------------------+-------------------+
| Feature                | Context+Reducer   | Redux Toolkit     |
+------------------------+-------------------+-------------------+
| Setup                  | Zero deps         | Install RTK       |
| Selectors              | None              | useSelector       |
| Middleware              | None              | Built-in          |
| Devtools               | None              | Redux DevTools    |
| Re-render control      | Split contexts    | Automatic         |
| Async logic            | Manual            | createAsyncThunk  |
| Best for               | Small/simple apps | Medium/large apps |
+------------------------+-------------------+-------------------+

When to use Context + useReducer:
  - Theme, auth, locale (infrequent changes)
  - Small apps with 1-3 state domains
  - No need for middleware or devtools

When NOT to use it:
  - Frequently updating state (mouse position, animations)
  - Large apps with many state domains (Provider hell)
  - Need selectors, middleware, or time-travel debugging

Previous: Lesson 6.1 — When You Need State Management -> Next: Lesson 6.3 — Redux Toolkit — The Modern Way ->


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

On this page