DEV Community

Robert
Robert

Posted on • Updated on • Originally published at blog.robertbroersma.com

Undo/Redo in React Using XState

I recently came across the need for undo and redo functionality in my app.

The app is an editor of sorts that allows you to add stuff and remove stuff using several different tools and keyboard shortcuts. All implemented using xstate.

It would be great to be able to undo and redo actions taken in the editor! Let's see how we can implement the undo/redo pattern from the Redux docs in XState.

Let's say we have the following machine:

const editorMachine = Machine(
  {
    id: "editor",
    context: {
      items: []
    },
    initial: "normal",
    on: {
      DELETE_SHAPE: {
        actions: ["deleteShape"]
      }
    },
    states: {
      normal: {
        on: {
          TOGGLE_MODE: "turbo",
          ADD_SHAPE: {
            actions: ["addShape"]
          }
        }
      },
      turbo: {
        on: {
          TOGGLE_MODE: "normal",
          ADD_SHAPE: {
            actions: ["addThreeShapes"]
          }
        }
      }
    }
  },
  {
    actions: {
      addShape: assign({
        items: (ctx, e) => [...ctx.items, e.shape]
      }),
      addThreeShapes: assign({
        items: (ctx, e) => [...ctx.items, e.shape, e.shape, e.shape]
      }),
      deleteShape: assign({
        items: (ctx, e) => [
          ...ctx.items.slice(0, e.index),
          ...ctx.items.slice(e.index + 1)
        ]
      })
    }
  }
);

Enter fullscreen mode Exit fullscreen mode

Which matches the following visualization:
State Machine

There's basically 2 states:

  • Normal, in which you can add 1 shape at a time to the list of items.
  • Turbo, in which you can add 3 shapes at a time to the list of items.

In both states you can delete a shape (you pass the shape's list index to the event, e.g. by clicking on it).

To be able to undo/redo our mutations to the items context, we need to do a few things (taken from The Redux Doc on Undo/Redo):

Handling Undo

  • Remove the last element from the past.
  • Set the present to the element we removed in the previous step.
  • Insert the old present state at the beginning of the future.

Handling Redo

  • Remove the first element from the future.
  • Set the present to the element we removed in the previous step.
  • Insert the old present state at the end of the past.

Handling Other Actions

  • Insert the present at the end of the past.
  • Set the present to the new state after handling the action.
  • Clear the future.

Here's what that looks like in our Machine:

const editorMachine = Machine(
  {
    id: "editor",
    context: {
      // Keep track of the past
      past: [],

      // Our present
      items: [],

      // Keep track of the future
      future: []
    },
    initial: "normal",
    states: {
      on: {
        DELETE_SHAPE: {
          // Update the past when we delete a shape
          actions: ["updatePast", "deleteShape"]
        },
        UNDO: {
          actions: ["undo"]
        },
        REDO: {
          actions: ["redo"]
        }
      },
      normal: {
        on: {
          TOGGLE_MODE: "turbo",
          ADD_SHAPE: {
          // Update the past when we add a shape
            actions: ["updatePast", "addShape"]
          }
        }
      },
      turbo: {
        on: {
          TOGGLE_MODE: "normal",
          ADD_SHAPE: {
            // Update the past when we add 3 shapes
            actions: ["updatePast", "addThreeShapes"]
          }
        }
      }
    },
  },
  {
    actions: {
      addShape: assign({
        items: (ctx, e) => [...ctx.items, e.shape]
      }),
      addThreeShapes: assign({
        items: (ctx, e) => [...ctx.items, e.shape, e.shape, e.shape]
      }),
      deleteShape: assign({
        items: (ctx, e) => [
          ...ctx.items.slice(0, e.index),
          ...ctx.items.slice(e.index + 1)
        ]
      }),

      // # Handling Other Actions
      updatePast: assign({
        // 1. Insert the present at the end of the past.
        past: ctx => [...ctx.past, ctx.items],

        // 2. Set the present to the new state after handling the action.
        // ! This happens in the 3 specific actions above

        // 3. Clear the future.
        future: []
      }),

      // # Handling Undo
      undo: assign(ctx => {
        const previous = ctx.past[ctx.past.length - 1];

        // 1. Remove the last element from the past.
        const newPast = ctx.past.slice(0, ctx.past.length - 1);
        return {
          past: newPast,

          // 2. Set the present to the element we removed in step 1.
          items: previous,

          // 3. Insert the old present state at the beginning of the future.
          future: [ctx.items, ...ctx.future]
        };
      }),

      // # Handling Redo
      redo: assign(ctx => {
        const next = ctx.future[0];

        // 1. Remove the first element from the future.
        const newFuture = ctx.future.slice(1);
        return {

          // 2. Set the present to the element we removed in step 1.
          items: next,

          // 3. Insert the old present state at the end of the past.
          past: [...ctx.past, ctx.items],
          future: newFuture
        };
      })
    }
  }
);
Enter fullscreen mode Exit fullscreen mode

And that's all! Now we have all the benefits of state machines combined with an undo/redo system on our extended state. We can craft a robust, complex editor (think of all the tools in the Photoshop toolbox) while keeping our undo/redo system simple!

Check out the CodeSandbox for an implemented example.

Top comments (6)

Collapse
 
ruiorey profile image
Rui Luís d'Orey

Hey Robert, nice approach and excelent article!

What would you say in a case we have several actor machines like in a 3d editor (3d objects with transforms, materials, etc) and we want to have a general application undo/redo? would you use a top level machine that manages and gets notifications on every context changes by all the child machines? And saves those aggregated changes and reapplies them if needed on the child machines? Or would you have individual by machine undo/redo logic and some way to know what was the latest changed machine and make it undo/redo, (also from some top machine I guess).
Thanks!

Collapse
 
robertbroersma profile image
Robert

Hey Rui,

That's a good question!

I think I would go with a top-level kind of history machine like in your last example, to keep the concerns of the machines separated.

One way you could go about this is to implement the Command Pattern with undoable commands.

This works well because your history machine just needs to keep a stack of the commands in memory, as opposed to a stack of ALL previous (and some future) state.

Hope that helps!

Collapse
 
ruiorey profile image
Rui Luís d'Orey

Hey Robert,
thanks for the answer.
Yes one classical way is using Command Pattern, now what I'm trying to get around is how to model that using XState (or not, maybe taking that out of Xstate) in an elegant and safe way.
I will also make this question on spectrum.chat/statecharts to see what other suggestions might come up.
Thanks a lot :)

Collapse
 
marcelkalveram profile image
marcelkalveram

Nice solution! I've had a very similar problem recently and tried out the approach above, but I also needed fully context support (it should preserve the context object when going back), so I built my xState wrapper which supports this: github.com/marcelkalveram/xstate-u...

It's not perfect and has other downsides (no redo, doesn't work with interpreted machines) but I thought I'd share it regardless in case anyone stumbles upon this post and is looking for the kind of wrapper I made.

Collapse
 
chautelly profile image
John

Really cool! Did you look into using the history state at all? (I have no clue if that would apply or work at all tbh.)

Collapse
 
robertbroersma profile image
Robert

Thanks!

I believe history state is for telling the machine to go back to the last state value of that region. So when returning to a sub-machine for instance you could start in something other than the initial state.

Similar, but this is for returning to a different infinite state!