Undo/redo actions by composing Redux Reducers (or: how do the Redux DevTools work?)

One of the reasons I created A Grip on Git was that there were some things with which I wanted to play. One of those things was Redux, a library that greatly simplifies state management in your Javascript applications. It helps you to be more explicit about possible changes in your application state by defining all possible state transformations as (pure) functions (referred to as reducers in the Redux documentation).

This has many benefits for you as a developer. One such benefit is that it enables a very cool project called Redux DevTools. DevTools allows you to undo and redo actions you performed earlier on demand, bringing a running app into the state it would have been in had those actions never happened.

A showcase of Redux DevTools.

In A Grip on Git, I wanted to do something similar. As you scroll down the tutorial, Git commands are executed as appropriate to that point in the tutorial. When you scroll back up, however, the visualisation should transform back to the previous state, as if the later commands had never happened. To explain how this works, let's first look at reducers.

How did reducers work, again?

Reducers are simple functions that take the current state and an action, and return the new state:

(state, action) => newState

To keep your app maintainable, it is often advisable to split up your reducers to only concern themselves with parts of the state. For example, you can have a gitCommands reducer that can only manipulate repository part of the application state, and a view reducer that can only manipulate the view part of the state:

import gitCommandReducer from 'gitCommandReducer.js';
import viewReducer from 'viewReducer.js';

(state, action) => {
  return {
    repository: gitCommandReducer(state.repository, action),
    view: viewReducer(state.view, action),
  };
}

Since this pattern is so common, Redux includes a utility called combineReducers with which you can remove the boilerplate. We won't be using that where we're going, though.

Snapshotting

The reducers demonstrated above stand on equal footing. To enable undoing and redoing, however, we're going to create a reducer that wraps the other reducers, applying to the application state as a whole. This snapshotReducer will be active in these three cases:

  1. When an action is a Git command, it will keep track of it.
  2. When an action is of type VISIT_SECTION, and that section hasn't been visited before, it will save a snapshot of the commands resulting in the current state.
  3. When an action is of type VISIT_SECTION, and that section has been visited before, it will restore the state the way it was when that section was first visited. It will do so by replaying all the actions kept track of in step 1, repeatedly applying the gitCommandReducer starting from an empty repository.

The code looks something like this:

// Reducers
import gitCommandReducer from 'gitCommandReducer.js';

// Possible action types
import { GIT_COMMIT, GIT_PUSH } from 'gitCommandActions.js';
import { VISIT_SECTION } from 'viewActions.js';

const snapshotReducer = (state, action) => {
  // Case 1: keep track of Git commands
  if(
    action.type === GIT_COMMIT ||
    action.type === GIT_PUSH
    // etc.
  ){
    const history = state.history || [];
    return {
      ...state,
      ...{
        history: history.concat(action),
      },
    };
  }

  // Case 2: save a snapshot
  if(!state.snapshots[action.sectionName]){
    return {
      ...state,
      ...{
        snapshots: {
          ...state.snapshots,
          ...{
            [action.sectionName]: state.history,
          },
        },
      },
    };
  }

  // Case 3: restore the previously seen state
  if(state.snapshots[action.sectionName]){
    return {
      ...state,
      ...{
        // This is why they're called reducers:
        repository: state.snapshots[action.sectionName]
                    .reduce(gitCommandReducer, {}),
      },
    };
  }
}

Now all that's left to do is wrapping the state produced by our original reducers:

import gitCommandReducer from 'gitCommandReducer.js';
import viewReducer from 'viewReducer.js';

(state, action) => {
  return {
    repository: gitCommandReducer(state.repository, action),
    view: viewReducer(state.view, action),
  };
}

…with the state as produced by our new snapshotReducer:

import gitCommandReducer from 'gitCommandReducer.js';
import viewReducer from 'viewReducer.js';
import snapshotReducer from 'snapshotReducer.js';

(state, action) => {
  return snapshotReducer(
    {
      repository: gitCommandReducer(state.repository, action),
      view: viewReducer(state.view, action),
    },
    action
  );
}

The bottom line

Redux's elegant uncoupling of state management has many benefits. By composing our reducers, we can implement undo/redo functionality without needing to alter the original reducers. Likewise, Redux DevTools work on any Redux app regardless of what its reducers do. By wrapping it around your app's reducers, it can keep track of all actions coming in, and replay them using your reducers when necessary.

This is just one of the many ways Redux can improve your life as a developer, so if you haven't tried it yet, I highly encourage you to do so.

License

This work by Vincent Tunru is licensed under a Creative Commons Attribution 4.0 International License.


Redux