useReducer and useContext

Managing complex state transitions and sharing values without prop drilling.

What you'll learn

  • When a reducer is clearer than several useState calls
  • Writing a pure reducer and dispatching actions
  • Passing a value down the tree with context
  • Combining the two, and when not to

When useState stops scaling

Several pieces of state that always change together are a reducer in disguise. Each handler has to remember to update all of them, and forgetting one produces an impossible combination — loading and an error, say.

A reducer puts every transition in one place. It is a pure function:

(currentState, action) => nextState

Components no longer describe how state changes. They dispatch an action saying what happened, and the reducer decides what that means:

import { useReducer } from "react";

const initialState = { count: 0, step: 1, history: [] };

// Pure: no fetching, no timers, no mutation — just a new state object.
function reducer(state, action) {
  switch (action.type) {
    case "increment":
      return {
        ...state,
        count: state.count + state.step,
        history: [...state.history, state.count],
      };
    case "setStep":
      return { ...state, step: action.value };
    case "reset":
      return initialState;
    default:
      throw new Error("Unknown action: " + action.type);
  }
}

export default function App() {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <div style={{ fontFamily: "sans-serif", padding: 16, fontSize: 13 }}>
      <p>count: {state.count} (step {state.step})</p>

      <button onClick={() => dispatch({ type: "increment" })}>Increment</button>
      <button onClick={() => dispatch({ type: "setStep", value: state.step + 1 })} style={{ marginLeft: 8 }}>
        Bigger step
      </button>
      <button onClick={() => dispatch({ type: "reset" })} style={{ marginLeft: 8 }}>
        Reset
      </button>

      <p>history: [{state.history.join(", ")}]</p>
    </div>
  );
}

useReducer returns the current state and a dispatch function, mirroring useState's pair. The advantages appear as the state grows: every transition is in one readable list, dispatch is stable so it never breaks a dependency array, and the reducer is a plain function you can test without rendering anything.

Reducers must be pure

Same state and action in, same state out, every time — no mutation, no requests, no Math.random(). React may call your reducer more than once for the same action while checking your code in development, so an impure reducer produces results that change depending on how often React looked at it.

Side effects go in the event handler that dispatches, or in an effect.

useState or useReducer

Neither is more advanced. Use useState for independent values — a toggle, a field, a counter. Reach for useReducer when the next state depends on the previous one in more than a trivial way, when several values move together, or when the same update is dispatched from many places.

useContext: skipping the middle

Passing a prop through components that do not use it — prop drilling — is noisy and couples every layer to the data. Context lets a provider publish a value that any descendant can read directly:

import { ThemeProvider, useTheme } from "./ThemeContext.js";

// Note: Toolbar receives no props, and passes none down.
function Toolbar() {
  return <ThemedButton />;
}

function ThemedButton() {
  const { theme, toggle } = useTheme();

  return (
    <button
      onClick={toggle}
      style={{
        background: theme === "dark" ? "#222" : "#eee",
        color: theme === "dark" ? "#fff" : "#222",
        padding: "6px 12px",
      }}
    >
      theme is {theme}
    </button>
  );
}

export default function App() {
  return (
    <ThemeProvider>
      <div style={{ fontFamily: "sans-serif", padding: 16 }}>
        <Toolbar />
      </div>
    </ThemeProvider>
  );
}

Three things to notice. Toolbar is untouched by the theme — that is the point. The context is exposed through a useTheme hook rather than raw useContext, so callers get a clear error instead of null when the provider is missing. And a component reads the context of the nearest provider above it, so providers can be nested to scope values to part of the tree.

Combining them

The common pattern for app-wide state: a reducer owns the transitions, and context delivers the state and dispatch to anyone who needs them.

import { CartProvider, useCart } from "./CartContext.js";

function ProductList() {
  const { dispatch } = useCart();

  return (
    <div style={{ display: "flex", gap: 8 }}>
      {["Book", "Pen", "Desk"].map((item) => (
        <button key={item} onClick={() => dispatch({ type: "add", item })}>
          Add {item}
        </button>
      ))}
    </div>
  );
}

function CartSummary() {
  const { items, dispatch } = useCart();

  if (items.length === 0) {
    return <p>Cart is empty.</p>;
  }

  return (
    <div>
      <p>{items.length} item(s): {items.join(", ")}</p>
      <button onClick={() => dispatch({ type: "clear" })}>Clear</button>
    </div>
  );
}

export default function App() {
  return (
    <CartProvider>
      <div style={{ fontFamily: "sans-serif", padding: 16, fontSize: 13 }}>
        <ProductList />
        <CartSummary />
      </div>
    </CartProvider>
  );
}

ProductList and CartSummary are siblings sharing state, with no common parent threading props between them.

The cost of context

Every component reading a context re-renders when the provider's value changes. If you put a single object in one context at the root of your app, a change to any part of it re-renders every consumer of the whole thing.

Two habits keep that in check: split unrelated data into separate contexts, and do not reach for context for everything. Passing props is fine, and often clearer — context earns its place when data is genuinely needed at many depths, like a theme, the current user, or a locale.

Common mistakes

  • An impure reducer. No fetching or mutation inside it.
  • Mutating the state in a reducer. Return a new object, as always.
  • Reading a context with no provider. Wrap the consumer, and throw a clear error in your hook.
  • One giant context. Split it, or accept that everything re-renders.

Exercise

The reducer below mutates its state, so the count never updates on screen even though the actions are dispatched. Make it return new state instead.

import { useReducer } from "react";

function reducer(state, action) {
  switch (action.type) {
    case "increment":
      state.count = state.count + 1; // mutation
      return state;
    case "reset":
      state.count = 0; // mutation
      return state;
    default:
      throw new Error("Unknown action");
  }
}

export default function App() {
  const [state, dispatch] = useReducer(reducer, { count: 0 });

  return (
    <div style={{ fontFamily: "sans-serif", padding: 16 }}>
      <p>count: {state.count}</p>
      <button onClick={() => dispatch({ type: "increment" })}>Increment</button>
      <button onClick={() => dispatch({ type: "reset" })} style={{ marginLeft: 8 }}>
        Reset
      </button>
    </div>
  );
}