This page looks best with JavaScript enabled

Easy undo feature with React and Immer.js

 ·  ☕ 6 min read  ·  ✍️ Iskander Samatov

undo with React


Adding the undo feature to React application from scratch is a fairly complex task. Managing state is already tricky, and implementing undo means having state versioning and tracking changes over time. Luckily, Immer.js makes it a lot simpler.

Let’s explore using Immer.js for managing complex states and handling undo while building a fun little dungeon crawler game.

undo with React finished app
Finished game

What is Immer.js?

Immer.js is a wonderful library that has a wide range of applications. But its core use case is providing seamless immutability . It lets you modify data structures and returns copies instead of mutating the original.

You might be aware of other libraries that accomplish the same thing, mainly Immutable.js . The main reason Immer.js is a better option, among many others, is the fact that it works with native object types.

The biggest drawback of Immutable.js is that it provides a custom type for each data structure. Immer.js, on the other hand, returns the same object types it receives, which makes it a lot less intruding and easier to work with.

“produce” function of Immer.js

The main feature of Immer.js is the produce function. The function accepts a source object and your custom handler for modifying the source. produce returns a new copy while leaving the source intact:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import produce from "immer"

const baseState = [
    {
        todo: "Learn typescript",
        done: true
    },
    {
        todo: "Try immer",
        done: false
    }
]

const nextState = produce(baseState, draftState => {
    draftState.push({todo: "Tweet about it"})
    draftState[1].done = true
})

Within the handler, you receive a draft parameter that you can use to mutate the source directly without the fear of side effects. The handler doesn’t have to return the draft. Immer.js is smart enough to track changes without the explicit return.

Immer uses the copy-on-write mechanism, which makes duplicating objects performant.

If you would like to learn more about Immer.js, the best place to start is their documentation .

Overview of patches

Building the undo feature involves using a more advanced functionality of Immer.js - patches .

Using patches, Immer.js keeps track of all the changes made to the state. The patches are JSON objects that follow a format Immer.js understands. Here’s what a patch looks like:

1
2
3
4
5
  {
    "op": "replace",
    "path": ["profile"],
    "value": {"name": "Veria", "age": 5}
  }

You can feed these patches back to Immer.js to reverse or reapply state changes using the applyPatches function.

Tutorial

With the basic overview of Immer.js done, let’s get started with our tutorial.

As mentioned before, we will build a simple game. You are in a dungeon and have three doors in front of you. One of them has the treasure you seek, while the other two have deadly enemies behind them.

The most important feature of the game (for us) is that you can cheat using the undo button.

Adding Reducer

We will use useReducer to manage the state of the application. Here’s the initial setup of our reducer:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import { generateDoorsContent } from "./utils";

const generateDoors = () => {
  const initialContent = generateDoorsContent();
  return [
    { behind: initialContent[0], isOpen: false },
    { behind: initialContent[1], isOpen: false },
    { behind: initialContent[2], isOpen: false }
  ];
};

export const INITIAL_STATE = {
  doors: generateDoors()
};

export const RESET = "RESET";
export const OPEN_DOOR = "OPEN_DOOR";
export const UNDO = "UNDO";

export const reducer = (state, action) => {
  return produce(
    state,
    (draft) => {
      switch (action.type) {
        case OPEN_DOOR: {
          const { index } = action.payload;

          draft.doors[index].isOpen = true;
          break;
        }
        case RESET: {
          draft.doors = generateDoors();
          break;
        }

        default:
          break;
      }
    }
  );
};

Our reducer returns a new state using the produce function. All the state manipulation happens inside of the produce handler. Notice again how we can freely mutate the state, and everything still works. Pretty cool!

generateDoors returns an array of doors with randomized content.

We use our reducer the same way we normally would:

1
2
  const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
  const { doors } = state;

Adding patches

Now lets use patches.

The produce function actually can accept three parameters:

  1. Initial source object. In our case, it’s the state of the reducer.
  2. Handler function for state mutation.
  3. A second handler that receives patches. We will need to store these patches for later use.

The patch handler function receives two parameters:

  1. patches - an array of patches with the latest state changes.
  2. inversePatches - an array of patches for reverting the latest state changes.

In our case, we only care about the inversePatches array. However, if you were to implement the redo, you would want to keep track of patches too.

Let’s provide the handler that will record inverse patches as they arrive:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import produce, { enablePatches } from "immer";

// some previous code is ommited for brevity

enablePatches();

let inverseChanges = [];

export const reducer = (state, action) => {
  return produce(
    state,
    (draft) => {
      switch (action.type) {
        case OPEN_DOOR: {
          const { index } = action.payload;

          draft.doors[index].isOpen = true;
          break;
        }
        case RESET: {
          draft.doors = generateDoors();
          inverseChanges = [];
          break;
        }
        default:
          break;
      }
    },
    (patches, inversePatches) => {
      if (action.type === OPEN_DOOR) {
        inverseChanges.push(...inversePatches);
      }
    }
  );
};

Note that we need to call enablePatches to activate the patches feature.

We check to make sure we only track patches for undoable actions. In our case, OPEN_DOOR is the only undoable action.

Also, when resetting the game, we want to clear patches in the RESET action.

Adding undo

The final step here is adding the UNDO action that applies the inverse patches we store:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import produce, { applyPatches, enablePatches } from "immer";
enablePatches();

export const RESET = "RESET";
export const OPEN_DOOR = "OPEN_DOOR";
export const UNDO = "UNDO";

let inverseChanges = [];

export const reducer = (state, action) => {
  return produce(
    state,
    (draft) => {
      switch (action.type) {
        case OPEN_DOOR: {
          const { index } = action.payload;

          draft.doors[index].isOpen = true;
          break;
        }
        case RESET: {
          draft.doors = generateDoors();
          inverseChanges = [];
          break;
        }
        case UNDO: {
          if (inverseChanges.length > 0) {
            return applyPatches(state, [inverseChanges.pop()]);
          }
          break;
        }

        default:
          break;
      }
    },
    (patches, inversePatches) => {
      if (action.type === OPEN_DOOR) {
        inverseChanges.push(...inversePatches);
      }
    }
  );
};

We pop the latest inverse patch from the inverseChanges array and apply it. Note that when calling applyPatches, Immer.js requires us to explicitly return the result. More on that here.

Wrapping up

That’s it for this tutorial. We saw how easy it is to add the undo feature to React with Immer.js.

Immer.js is a great immutability library, and patches allow for creating some advanced functionality. You can use patches for building features like communicating updates over WebSockets, tracing/debugging, versioning, and more.

Here’s a link to the codesandbox with the full code of this tutorial.

Share on

Software Development Tutorials
WRITTEN BY
Iskander Samatov
The best up-to-date tutorials on web and mobile development.