open atlas
↑ Back to track
React, zero to senior RCT · 06 · 02

Reducers and context: turning scattered setState into testable transitions

useReducer models multi-field updates as named transitions in one pure, unit-testable function instead of scattered setState calls. Split state and dispatch into two contexts — dispatch is stable, so write-only components never re-render. Often this replaces a state library.

RCT Middle ◷ 18 min
Level
FoundationsJuniorMiddleSenior

The checkout bug that reached the CFO was simple to state and miserable to reproduce: an order shipped with free shipping and the shipping fee included in the total. The checkout component held eleven useState fields — items, promo code, discount, shipping method, shipping cost, totals — and the invariant “total = items − discount + shipping” was maintained by hand in six different event handlers. The “apply promo” handler updated discount and total; the “change shipping” handler updated shipping and total; a teammate had added express-checkout a month earlier and updated five of the six places. QA never caught it because the broken path required applying a promo while the shipping estimate was still loading. The rewrite was almost mechanical: one useReducer, actions named promo-applied and shipping-selected, totals recomputed in exactly one place — the reducer — and a twelve-line unit test that replayed the failing action sequence with no DOM, no React, no mocking. The test failed against the old logic, passed against the new, and the bug class was gone.

By the end of this lesson you’ll know exactly when scattered setState is a bug waiting to happen — and how one pure function eliminates the whole class.

Transitions, not fields: what useReducer actually changes

A form with ten useState calls has no architecture — it has ten independent values and a prayer that every event handler updates the right subset. The failure mode is invariant drift: a rule like “total reflects discount and shipping” lives in no single place, so each new handler is a chance to update four of the five fields it should. useReducer inverts the structure. State becomes one object; updates become actions — named events describing what happened (promo-applied), not which fields to write (setDiscount + setTotal). The reducer is the single place where events turn into the next state:

function checkoutReducer(state, action) {
  switch (action.type) {
    case "promo-applied": {
      const discount = computeDiscount(state.items, action.code);
      return withTotals({ ...state, promoCode: action.code, discount });
    }
    case "shipping-selected": {
      return withTotals({ ...state, shipping: action.method });
    }
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

// withTotals is the ONE place the invariant lives:
function withTotals(state) {
  return { ...state, total: subtotal(state) - state.discount + shippingCost(state) };
}

const [state, dispatch] = useReducer(checkoutReducer, initialCheckout);
// Handlers shrink to intent: dispatch({ type: "promo-applied", code });

Mechanically, dispatch schedules a re-render and React calls your reducer with the current state and the action during the next render — which is why the reducer must be pure: no fetches, no Date.now(), no mutation of the incoming state (in StrictMode the reducer is double-invoked to catch exactly this). Components shrink to dispatching intent; the decision logic leaves the component entirely.

Pure logic is testable logic

Ask yourself: how do you write a regression test for a bug that only appears when two events fire in a specific order, with no DOM? With a reducer, the answer is three lines. Because the reducer is a pure function (state, action) => state, the most bug-prone logic in the feature — multi-field invariants, edge-case sequencing — becomes unit-testable without rendering anything. The Hook’s regression test is three lines of replay: start from a known state, dispatch shipping-estimate-loaded then promo-applied, assert the total. No JSDOM, no act(), no mocked network — reducer tests run in microseconds and pin down sequences, which is precisely where scattered-setState bugs hide. This is the underrated payoff: teams adopt reducers “for organization” and discover the real win is that state logic finally has the same test discipline as the rest of the codebase. Tradeoff: the ceremony is real — action types, a switch, an indirection between the click and the write. For two fields with no invariants, useState is strictly better; a reducer earns its keep when fields update together under rules.

Splitting state and dispatch: re-render hygiene as architecture

To share reducer state across a feature subtree, you pass it through context — and here the re-render hygiene from unit 02 becomes an architectural decision. A context re-renders every consumer when its value changes identity. If you publish { state, dispatch } as one object, every consumer re-renders on every state change — including toolbar buttons that only ever dispatch. The fix is structural: two contexts.

const CheckoutStateContext = createContext(null);
const CheckoutDispatchContext = createContext(null);

function CheckoutProvider({ children }) {
  const [state, dispatch] = useReducer(checkoutReducer, initialCheckout);
  return (
    <CheckoutDispatchContext.Provider value={dispatch}>
      <CheckoutStateContext.Provider value={state}>
        {children}
      </CheckoutStateContext.Provider>
    </CheckoutDispatchContext.Provider>
  );
}

React guarantees dispatch is referentially stable across renders — the dispatch context’s value never changes, so write-only components (buttons, menu items, the express-checkout banner) subscribe to it and never re-render from state changes. Read-only components subscribe to the state context and re-render when state changes — which is honest, because they display it.

Why this works

Why does this pattern often replace a state library? Because for a feature-scoped domain — checkout, an editor pane, a wizard — it already provides the three things people import Redux for: centralized transitions, testable pure logic, and shared access down a subtree. What it does not provide is what libraries actually add: selector-grade subscription precision (every state-context consumer re-renders on any state change — there is no way to subscribe to just state.total), devtools with action history, and middleware. The decision rule: feature-scoped, modest write rate, consumers mostly read overlapping state → reducer + context, zero dependencies. App-wide, write-heavy, or consumers reading disjoint slices → an external store, next lesson.

Quiz

A checkout form maintains the invariant 'total = items − discount + shipping' across six event handlers using multiple useState calls, and the invariant keeps breaking. What does moving to useReducer structurally change?

Quiz

Why does splitting reducer state and dispatch into two separate contexts stop write-only components from re-rendering, when one combined context could not?

Recall before you leave
  1. 01
    A multi-field form keeps breaking an invariant that several event handlers each maintain by hand. Explain how a reducer eliminates that bug class and why the reducer must stay pure.
  2. 02
    When does reducer + context legitimately replace a state library, and what are you giving up — name the precise re-render limitation.
Recap

Multiple useState fields that update together under rules are an invariant waiting to drift: the rule lives in every handler and therefore in none, and the bug arrives with the next handler someone adds. useReducer restructures the feature around transitions — handlers dispatch named actions describing what happened, and one pure function turns the current state and the action into the next state. Invariants get a single home; components shrink to expressing intent. Mechanically, dispatch schedules a re-render and React runs the reducer during rendering, which is why it must be pure — no fetches, no clock reads, no mutation; StrictMode double-invokes it to catch violations. Purity buys the practical prize: state logic becomes unit-testable by replaying action sequences in microseconds, no DOM or mocking, which is how sequence-dependent bugs get regression tests. Sharing the reducer down a subtree applies re-render hygiene architecturally: publish state and dispatch as two separate contexts. Dispatch is referentially stable, so write-only components never re-render; state-context consumers re-render on every transition — honestly for displays, wastefully for components reading an unchanged slice, because context has no selectors. That missing precision, plus devtools and middleware, is what a state library still adds: reducer + context for feature-scoped domains, external stores when writes are heavy or readers are disjoint. Now when you see a component that only dispatches but re-renders on every state change, you’ll know the split-context fix in thirty seconds.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.