Create A Vuex Undo/Redo Plugin For VueJS

Anthony Gore | November 12th, 2017 | 6 min read

vue.js vuex plugins


There are many benefits to centralizing your application state in a Vuex store. One benefit is that all transaction are recorded. This allows for handy features like time-travel debugging where you can jump between previous states to isolate problems.

In this article, I'll demonstrate how to create an undo/redo feature with Vuex, which works in a similar way to time-travel debugging. This feature could be used in a variety of scenarios from complex forms to browser-based games.

You can check out the completed code here on Github, and try a demo in this Codepen:

<h1>Click around the square</h1>
<div id="app" v-cloak>
  <canvas-component ref="canvas"></canvas-component>
  <div class="buttons">
    <button v-if="canUndo" @click="undoDraw">Undo</button>
    <button v-else disabled>Undo</button>
    <button v-if="canRedo" @click="redoDraw">Redo</button>
    <button v-else disabled>Redo</button>
  </div>
</div>
#canvas {
    background: lightblue;
    width: 400px;
    height: 400px;
    margin: 0 auto;
    display: block;
}

.buttons {
    text-align: center;
    padding: 16px;
}

.buttons button {
    font-size: 24px;
    padding: 6px;
    border-radius: 3px;
    margin: 0 8px;
}

[v-cloak] {
    display: none;
}

h1 {
    text-align: center;
    font-family: system-ui;
    font-weight: 400;
}
let store = new Vuex.Store({
  state: {
    coords: []
  },
  mutations: {
    addCoords(state, payload) {
      state.coords.push(payload);
    },
    emptyState() {
      this.replaceState({ coords: [] });
    }
  }
});

let CanvasComponent = {
  template: `<canvas id="canvas" @click="clicked"></canvas>`,
  mounted() {
    let canvas = this.$el;
    canvas.width = 400;
    canvas.height = 400;
    let context = canvas.getContext('2d');
    context.translate(0.5, 0.5);
  },
  methods: {
    drawLine(x1, y1, x2, y2) {
      let context = this.$el.getContext('2d');
      context.beginPath();
      context.moveTo(x1, y1);
      context.lineTo(x2, y2);
      context.lineWidth = 1;
      context.strokeStyle = 'black';
      context.stroke();
    },
    drawCircle(x, y) {
      let context = this.$el.getContext('2d');
      context.beginPath();
      context.arc(x, y, 5, 0, 2 * Math.PI, false);
      context.fillStyle = 'yellow';
      context.fill();
      context.lineWidth = 1;
      context.strokeStyle = 'black';
      context.stroke();
    },
    getMousePos(event) {
      var canvas = this.$el;
      var rect = canvas.getBoundingClientRect();
      return {
        x: (event.clientX - rect.left) / (rect.right - rect.left) * canvas.width,
        y: (event.clientY - rect.top) / (rect.bottom - rect.top) * canvas.height
      };
    },
    clicked(event) {
      this.$store.commit('addCoords', this.getMousePos(event));
      this.draw();
      this.undone = [];
    },
    draw() {
      canvas.width = 400;
      this.$store.state.coords.forEach((coords, i) => {
        this.drawCircle(coords.x, coords.y);
        let lastCoords = this.$store.state.coords[i - 1];
        if (lastCoords) {
          this.drawLine(lastCoords.x, lastCoords.y, coords.x, coords.y);
        }
      });
    }
  }
};

Vue.use(VuexUndoRedo);

new Vue({
  el: '#app',
  store,
  methods: {
    undoDraw() {
      this.undo();
      this.$refs.canvas.draw();
    },
    redoDraw() {
      this.redo();
      this.$refs.canvas.draw();
    },
  },
  components: {
    CanvasComponent
  }
});

Setting up a plugin

To make this feature reusable we'll create it as a Vue plugin. This feature requires us to add some methods and data to the Vue instance, so we'll structure the plugin as a mixin.

plugin.js

module.exports = {
  install(Vue) {
    Vue.mixin({
      // Code goes here
    });
  }
};

To use it in a project we can simply import the plugin and install it:

app.js

import VuexUndoRedo from './plugin.js';
Vue.use(VuexUndoRedo);

Concept

The feature will work by rolling back the last mutation if the user wants to undo, then re-applying it if they want to redo. How will we implement this?

Approach #1

The first possible approach is to "snapshot" the state of the store after every mutation and pushing the snapshot into an array. To undo/redo we can grab the correct snapshot and replace the store state with it.

Approach #2

Another possible approach is to log every mutation that is committed. To undo, we reset the store to its initial state and then re-run the mutations again; all but the last. Redoing is a similar concept.

Logging mutations

Vuex offers an API method for subscribing to mutations which we can use to log them. We'll set this up in the created hook. In the callback, we simply push the mutation into an array which can later be re-run.

plugin.js

Vue.mixin({
  data() {
    return {
      done: []
    }
  },
  created() {
    this.$store.subscribe(mutation => {
      this.done.push(mutation);
    }); 
  }
});

Undo method

To undo a mutation we will clear the store then re-run all the mutations except for the last one. Here's how the code works:

const EMPTY_STATE = 'emptyState';

Vue.mixin({
  data() { ... },
  created() { ... },
  methods: {
    undo() {
      this.done.pop();
      this.$store.commit(EMPTY_STATE);
      this.done.forEach(mutation => {
        this.$store.commit(`${mutation.type}`, mutation.payload);
        this.done.pop();
      });
    }
  }
});

Clearing the store

Whenever this plugin is used the developer must implement a mutation in their store called emptyState. This has the job of reverting the store back to its original state so it's ready to be re-built from scratch.

new Vuex.Store({
  state: {
    myVal: null
  },
  mutations: {
    emptyState() {
      this.replaceState({ myval: null });
    }
  }
});

Redo method

Let's create a new data property undone which will be an array. When we remove the last mutation from done during the undo process, we push it to this array:

Vue.mixin({
  data() {
    return {
      done: [],
      undone: []
    }
  },
  methods: {
    undo() {
      this.undone.push(this.done.pop());
      ...
    }
  }
});

We can now create a redo method which will simply take the last mutation added to undone and re-commit it.

methods: {
  undo() { ... },
  redo() {
    let commit = this.undone.pop();
    this.$store.commit(`${commit.type}`, commit.payload);
  }
}

Public API

You'll notice in my demo that the undo and redo buttons are disabled whenever their functionality is not currently allowed. For example, if there haven't been any commits yet, you obviously can't undo or redo. A developer using this plugin may want to implement similar functionality.

module.exports = {
  install(Vue) {
    Vue.mixin({
      data() { ... },
      created() { ... },
      methods: { ... },
      computed: {
        canRedo() {
          return this.undone.length;
        },
        canUndo() {
          return this.done.length;
        }
      },
    });
  },
}

About Anthony Gore

I'm Anthony Gore and I'm a web developer with a crush on Vue.js. I'm a Vue Community Partner, curator of the weekly Vue.js Developers Newsletter, and the creator of Vue.js Developers.

If you enjoyed this article, show your support by buying me a coffee. You might also enjoy taking one of my online courses!