Reducers: Keeping State Transitions Under Control

state-management frontend react design-patterns

State management is one of those problems that starts out simple and gradually becomes complicated so it’s no wonder there are plenty of libraries designed to help with this, with Redux being one of the most popular in the Javascript ecosystem.

One concept that is core to many state management libraries is a simple idea: the reducer pattern.

A reducer provides a controlled way of changing state. The focus of this article is to look at how it gives us somewhere to define the rules about how state is allowed to change, so that we are less likely to end up with invalid state.

The problem with managing state independently

Imagine an app that tracks a game with checkpoints. A player starts by entering their team name, then progresses through a series of checkpoints.

We might initially model the state like this:

type State = {
  page: "start" | "checkpoint" | "finished";
  checkpoint: number;
  teamName: string;
};

We could then update these properties directly:

state.page = "checkpoint";
state.checkpoint = 1;

And when the final checkpoint is completed:

state.page = "finished";

The problem is that nothing stops us from making changes that would put our application in an invalid state.

For example:

state.page = "finished";
state.checkpoint = 3;

If we have 5 checkpoints, we shouldn’t be showing the “finished” page, whilst also tracking that we’re on checkpoint 3.

We could also accidentally do something like:

state.page = "checkpoint";
state.teamName = "";

Now we’re displaying a checkpoint to a player who doesn’t have a team name.

The problem isn’t necessarily the individual properties. The problem is the relationships between them.

As the number of state properties increases, so does the number of possible combinations of those properties. Some of those combinations may be perfectly valid, while others make no sense.

The challenge is therefore not just storing state. It’s controlling how we get from one state to another.

Enter the reducer

A reducer is a function that takes the current state and an action, and returns the next state:

function reducer(state: State, action: Action): State {
  // ...
}

An action describes something that happened. For our checkpoint application, we might have:

type Action =
  | { type: "start"; teamName: string }
  | { type: "completeCheckpoint" }
  | { type: "skipCheckpoint" }
  | { type: "finish" };

Instead of directly modifying the state, the application dispatches an action:

dispatch({ type: "completeCheckpoint" });

The reducer then decides what the new state should be.

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "start":
      return {
        ...state,
        page: "checkpoint",
        checkpoint: 1,
        teamName: action.teamName
      };

    case "completeCheckpoint":
      return {
        ...state,
        checkpoint: state.checkpoint + 1
      };

    case "finish":
      return {
        ...state,
        page: "finished"
      };

    default:
      return state;
  }
}

All the responsibility for changing state now lives in this reducer. The application itself isn’t in charge of incrementing the current checkpoint, it simply sends the completeCheckpoint event to the reducer, and the reducer decides what should happen in response to this.

Actions describe events, not mutations

It’s useful to notice that the actions are describing the events, not mutations to the underlying data.

For example:

{ type: "completeCheckpoint" }

is preferable to something like:

{ type: "setCheckpoint", value: 4 }

If we later change how checkpoints work, we can change the reducer without having to change every caller that triggers the transition.

Note: Reducers are not a React concept. Most people will be familiar with reducers from the React hook useReducer, but the pattern itself is not specifically a React thing.

The Key Take Away

The main focus of this article was to illustrate that a reducer isn’t just a way of writing state updates, but is a way of having a centralised place to define how state changes over time.

Instead of allowing different parts of an application to independently manipulate pieces of state, we describe events and let the reducer decide how those events transform the current state.

For a simple application, a reducer may be completely unnecessary. But when state starts having relationships, dependencies and meaningful transitions, a reducer can really help.

comments powered by Disqus