JavaScript

Reducers, Explained

10 min read

Reducers trip up many newer programmers. On the web, the concept was popularized by Redux. But it’s been around much longer than that. Even in JavaScript.

Maybe you’ve seen some code like this before:

[1, 9, 20].reduce((prev, cur) => prev + cur, 0);

This is the sum function. The first argument is the reducer, and the second argument is the initial value for prev. When summing a list of numbers, the initial value is 0.

Here’s what happens at every stage:

[1, 9, 20].reduce((prev, cur) => {
  console.log({ prev, cur });
  return prev + cur;
}, 0);

If we don’t use the second argument (initialValue), reduce will take the first element in the array as the initial value, and start from the second element onward.

[1, 9, 20].reduce((prev, cur) => {
  console.log({ prev, cur });
  return prev + cur;
});

This is great if you’re writing a sum function, where the data type of the initial value is the same as prev, but may not work for other use cases. In that case, it’s a common pattern to pass in undefined, and initialize prev using default parameters:

[1, 9, 20].reduce((prev = 0, cur) => {
  console.log({ prev, cur });
  return prev + cur;
}, undefined);

This is what Redux does internally.

Even if you’ve never used reduce before, you’ve probably written the sum function imperatively before:

let prev = 0;
for (const cur of [1, 9, 20]) prev += cur;
prev;

All reducers can be written imperatively using a simple loop.

That’s great and all, but why not just stick to writing imperative code? You might have to write a little more code, but it’s a lot easier to read and understand.

Not all computation can be done eagerly. If you have a list with a scrollbar, you don’t necessarily need to calculate items you don’t see. More generally, if you’re using a UI, you take time to perform actions. The application does not know ahead of time what the result you want from it is. It needs to be able to respond to events: taps, typing, scrolling, you name it.

Reducers are basically repeated function calls, where you pass in the result of the previous call into the next. Summation can be implemented by repeatedly calling add:

const add = (x, y) => x + y;
add(add(add(0, 1), 9), 20);
const add = (x = 0, y) => x + y;
let state = add(undefined, 1);
state = add(state, 9);
state = add(state, 20);

Arrays are really just a tool to store the values for y. The reduce function repeatedly calls add with the accumulated value as x, and the current item as y.

If the operation is commutative, it becomes possible to efficiently parallelize computation with a reduce function. Work can be distributed multiple cores or even machines. This is the idea behind MapReduce.

If you have a list of 1,000,000 numbers and 10 cores, core #1 could sum 1…100,000, core #2 could sum 100,001…200,000, and so on. Finally, the cores send their computation to a predefined core (let’s say #10). This core now only has to sum 10 partial sums.

Many reduce functions are not commutative. A UI can behave very differently if just one of the actions performed is reordered. In this case, the reducer must be executed serially (in series) and cannot be computed in parallel. This constraint is why many UIs, including webpages, don’t bother with threads (multiple cores). Many tasks can still be offloaded, such as layout or parsing images, but it is not safe to start processing a click before you finished resolving the result to a previous click.

Text editors are a great example of an application that could be implemented with a reducer. With an undo and redo buffer, it is easy to revert or replay actions.

Back to an example of a Redux reducer. Here’s what one would look like:

const initialState = { value: 0 };
function counter(state = initialState, action) {
  if (action.type === "increment") {
    return { ...state, value: state.value + (action.payload ?? 1) };
  }
  return state;
}

prev is now called state, initialized to an initialState. action becomes an object containing type and payload fields.

const actionLog = [{ type: "initialize" }, { type: "increment" }, { type: "increment", payload: 9 }];
let state = actionLog.reduce(counter, undefined);
console.log(state);
console.log(counter(state, { type: "increment", payload: -5 }));

Few apps use only a single reducer. Adding another feature may mean writing another reducer. In order to have a single source of truth, you’ll have to combine reducers somehow.

Because Redux reducers only interact with actions they recognize, and return state otherwise, all we have to do is call every reducer for every action.

To initialize the store (state), Redux calls all your reducers with a special initialize action that it defines, and combines the result into a single object.

function auth(state = { signedIn: false }, action) {
  if (action.type === "login") return { ...state, signedIn: true };
  return state;
}
function counter(state = { value: 0 }, action) {
  if (action.type === "increment") {
    return { ...state, value: state.value + (action.payload ?? 1) };
  }
  return state;
}

function combine(...reducers) {
  return (state, action) => {
    if (action.type === "initialize") {
      let state = {};
      for (const reducer of reducers) {
        state = { ...state, ...reducer(undefined, action) };
      }
      return state;
    }

    for (const reducer of reducers) {
      state = reducer(state, action);
    }
    return state;
  };
}

const actionLog = [{ type: "initialize" }, { type: "increment", payload: 9 }, { type: "login" }];
const app = combine(auth, counter);
actionLog.reduce(app, undefined);

A reducer that’s been created from another one is called a transducer. Many things are possible besides combining reducers. Some examples include:

  • Filtering specific actions
  • Transforming the output of a reducer
  • Undo/redo
  • Persistence

Unless you’re doing something more complicated, like combining multiple reducers together, you don’t actually need any library whatsoever to unit test a reducer. Just write a list of actions and assert that the resulting state is what you expect.

Snapshots are the ideal way to test reducer outputs. Snapshots don’t work well with UIs (DOM trees), because you’re not testing what a user sees. But with business logic, the data you output makes up the complete interface for the end-user (your view layer).

Here are three ways to effectively use snapshot testing with reducers:

There might be issues with intermediate states, but this is the fastest way to get started.

const actionLog = [{ type: "initialize" }, { type: "increment" }, { type: "increment", payload: 9 }];
const state = actionLog.reduce(counter, undefined);
expect(state).toMatchSnapshot();

This will generate a lot of snapshots and can make it significantly more difficult to make iterative changes.

If you are generating multiple snapshots on the same line in our test, you’ll need to pass the test name to toMatchSnapshot. One way to do this is to wrap your reducer so you can access the currentIndex parameter of reduce:

const actionLog = [{ type: "initialize" }, { type: "increment" }, { type: "increment", payload: 9 }];
const state = actionLog.reduce((prev, cur, index) => {
  const stage = counter(prev, cur);
  expect(stage).toMatchSnapshot(`Stage ##{index + 1}`);
  return stage;
}, undefined);

This is a great choice if specific intermediate states are important to get right. Reducers are “resumable”: we can pass the result of a partial reduction into another reduce function through the use of initialValue.

let state = counter(undefined, { type: "initialize" }); // or just counter()
expect(state).toMatchSnapshot("initial");
state = [{ type: "increment" }, { type: "increment", payload: 9 }].reduce(counter, state);
expect(state).toMatchSnapshot("some increments");
state = [{ type: "increment", payload: -12 }, { type: "increment" }].reduce(counter, state);
expect(state).toMatchSnapshot("decrement");
state = counter(state, { type: "increment", payload: 2 });
expect(state).toMatchSnapshot("single action");

Try to use the first argument to toMatchSnapshot to label what your snapshots do.