Skip to the content.

Pure functions, and why a reducer has to be one

Pure functions, and why a reducer has to be one

A pure function has two properties: given the same inputs it always returns the same output, and it causes no side effects — it doesn’t read the clock, generate a random number, mutate its arguments, call an API, or log to a server. A reducer is required to be pure, and this is not dogma. Every valuable thing Redux gives you — predictable state, trivial tests, time-travel debugging, safe re-renders — is a consequence of that one rule. Break purity and you don’t just bend a guideline; you turn off the features you adopted Redux for.

A pure reducer maps state and action to a new state with no outside reads or writes State and action go into the reducer, a new state comes out. Side effects — clock, random, fetch, mutation — are shown crossed out because a pure reducer touches none of them. state action reducer (pure) new state ✗ clock ✗ random ✗ fetch ✗ mutate input
State + action in, new state out — and nothing else in or out. The crossed items are exactly what makes a reducer stop being a reducer.

The three ways reducers go impure

Almost every impure reducer commits one of three sins. It reads a nondeterministic source, so the same action gives different results; it mutates its input, so React’s reference check can’t see the change; or it performs a side effect, so replaying actions fires it again. Here is all three, and the fix:

// IMPURE — reads the clock, mutates state, and fires a request
function todos(state, action) {
  if (action.type === "ADD") {
    state.items.push({ text: action.text, at: Date.now() });  // mutate + clock
    fetch("/api/todos", { method: "POST" });                  // side effect
    return state;
  }
  return state;
}
// PURE — deterministic inputs, a new array, no effects
function todos(state, action) {
  if (action.type === "ADD") {
    return { ...state, items: [...state.items, action.item] }; // caller made `item`
  }
  return state;
}

Notice the timestamp and the request didn’t vanish — they moved. The at field is computed in the action creator (where reading the clock is fine), and the fetch moves to a thunk or saga. The reducer’s only job is the transition. This is the key mental shift: purity does not forbid side effects, it relocates them to the edges — action creators, middleware, effects — and keeps the core state logic a clean function you can reason about in isolation. When a reducer feels like it “needs” to do something impure, that is the signal a step belongs one layer out, not that the rule is inconvenient.

Purity is what makes the tests trivial

Because a pure reducer is just input-to-output, a test needs no store, no mocks, no framework — you call it and assert on the return value:

const next = todos({ items: [] }, { type: "ADD", item: { text: "x" } });
expect(next.items).toEqual([{ text: "x" }]);   // no setup, no teardown, no clock

That same property is what lets Redux DevTools replay your action log and land on exactly the state you had — time travel only works because re-running the reducers is guaranteed to reproduce the same states. And immutability, the “return a new object” half of purity, is what lets React and reselect detect a change with a === reference check instead of a deep comparison. So the purity rule is load- bearing: it is the single constraint that predictability, testability, and cheap change-detection all rest on. The counter-reducer exercise is the cleanest place to feel it — a reducer with nothing in it but a pure transition.