Create a Vuex Undo / Redo plugin for VueJS
- Transfer
- Tutorial
There are many advantages to centralizing the status of your application in the Vuex store. One advantage is that all transactions are recorded. This allows you to use handy features, such as run-time debugging , where you can switch between previous states to separate execution tasks.
In this article, I will show you how to create the Undo / Redo function further on the Rollback / Return using Vuex, which works similarly to debugging during debug. This feature can be used in various scenarios, from complex forms to browser-based games.
You can check out the finished code here on Github and try the demo in this Codepen . I also created a plugin as an NPM module called vuex-undo-redo if you want to use it in a project.
Note: this article was originally posted here on the developer blog Vue.js 2017/11/13
Plugin Setup
To make this function reusable, we will create it as a Vue plugin. This function requires us to add some methods and data to the Vue instance, so we structure the plugin as a mixin.
module.exports = {
install(Vue) {
Vue.mixin({
// Code goes here
});
}
};
To use it in the project, we can simply import the plugin and connect it:
import VuexUndoRedo from'./plugin.js';
Vue.use(VuexUndoRedo);
Idea
The work of the feature will be to roll back the last mutation if the user wants to cancel, and then reapply it if he wants to repeat. How do we do this?
Approach number 1
The first possible approach is to take "snapshots" of the state of the repository after each mutation and place the snapshot into an array. To undo / redo, we can get the correct snapshot and replace it with the storage status.
The problem with this approach is that the state of the repository is a JavaScript object. When you put a JavaScript object in an array, you simply place an object reference. A naive implementation, like the following, will not work:
var state = { ... };
var snapshot = [];
// Push the first state
snapshot.push(state);
// Push the second state
state.val = "new val";
snapshot.push(state);
// Both snapshots are simply a reference to stateconsole.log(snapshot[0] === snapshot[1]); // true
The snapshot approach will require that you first make a state clone before push. Given that the state of Vue becomes reactive due to the automatic addition of get and set functions, it does not work well with cloning.
Approach number 2
Another possible approach is to register each recorded mutation. To cancel, we reset the repository to its initial state and then rerun the mutations; all but the last. Return a similar concept.
Given the principles of Flux, restarting mutations from the same initial state should ideally recreate the state. Since this is a cleaner approach than the first, let's continue.
Mutation registration
Vuex offers an API method for subscribing to mutations that we can use to register them. We will install it on the hook created
. In the callback, we simply put the mutation into an array, which can later be re-run.
Vue.mixin({
data() {
return {
done: []
}
},
created() {
this.$store.subscribe(mutation => {
this.done.push(mutation);
}
}
});
Rollback method
To cancel the mutation, we will clear the repository, and then rerun all mutations except the last one. This is how the code works:
- Use
pop
the array method to remove the last mutation. - Clear the store state with a special mutation
EMPTY_STATE
(explained below) - Repeat each remaining mutation, fixing it again in the new store. Please note that the subscription method is still active during this process, that is, each mutation will be added again. Remove it right away with help
pop
.
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();
});
}
}
});
Cleaning store
Whenever this plugin is used, the developer must implement a mutation in his repository called emptyState. The challenge is to bring the store back to its original state so that it is ready for recovery from scratch.
The developer must do this on his own, because the plugin we create does not have access to the store, only to the Vue instance. Here is an example implementation:
new Vuex.Store({
state: {
myVal: null
},
mutations: {
emptyState() {
this.replaceState({ myval: null });
}
}
});
Returning to our plugin, the emptyState
mutation should not be added to our list done
, since we do not want to re-fix it in the rollback process. Prevent this with the following logic:
Vue.mixin({
data() { ... },
created() {
this.$store.subscribe(mutation => {
if (mutation.type !== EMPTY_STATE) {
this.done.push(mutation);
}
});
},
methods() { ... }
});
Return method
Let's create a new data property undone
that will be an array. When we remove the last mutation from done
the rollback process, we put it in this array:
Vue.mixin({
data() {
return {
done: [],
undone: []
}
},
methods: {
undo() {
this.undone.push(this.done.pop());
...
}
}
});
Now we can create a redo
method that will simply take the last added mutation undone
and re-fix it.
methods: {
undo() { ... },
redo() {
let commit = this.undone.pop();
this.$store.commit(`${commit.type}`, commit.payload);
}
}
No refund is possible.
If the user initiates a cancellation one or more times, and then makes a new new commit, the content undone
will be considered invalid. If this happens, we have to empty it undone
.
We can detect new commits from our callback subscription when adding a commit. However, the logic is tricky, since the callback has no obvious way to know what a new commit is and what undo / redo is.
The easiest approach is to set the newMutation flag. It will be true by default, but the rollback and return methods will temporarily set it to false. If mutation is set to true, the callback subscribe
will clear the array undone
.
module.exports = {
install(Vue) {
Vue.mixin({
data() {
return {
done: [],
undone: [],
newMutation: true
};
},
created() {
this.$store.subscribe(mutation => {
if (mutation.type !== EMPTY_STATE) {
this.done.push(mutation);
}
if (this.newMutation) {
this.undone = [];
}
});
},
methods: {
redo() {
let commit = this.undone.pop();
this.newMutation = false;
this.$store.commit(`${commit.type}`, commit.payload);
this.newMutation = true;
},
undo() {
this.undone.push(this.done.pop());
this.newMutation = false;
this.$store.commit(EMPTY_STATE);
this.done.forEach(mutation => {
this.$store.commit(`${mutation.type}`, mutation.payload);
this.done.pop();
});
this.newMutation = true;
}
}
});
},
}
The main functionality is now complete! Add the plugin to your own project or to my demo to test it.
Public API
In my demonstration, you will notice that the cancel and return buttons are disabled if their functionality is not currently possible. For example, if there were no commits yet, you obviously cannot undo or redo. A developer using this plugin may want to implement this functionality.
To allow this, a plugin can provide two calculated properties canUndo
and canRedo
as part of a public API. This is trivial to implement:
module.exports = {
install(Vue) {
Vue.mixin({
data() { ... },
created() { ... },
methods: { ... },
computed: {},
computed: {
canRedo() {
returnthis.undone.length;
},
canUndo() {
returnthis.done.length;
}
},
});
},
}