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.
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.