Keeping track of changes
With persistent changes, you leave behind a trail of breadcrumbs that lets you time travel back to any point in your data's past. Whether or not you choose to keep track of these changes is entirely up to you. The need to do this depends on what you're building. For example, it might be useful to be able to roll back changes made to immutable data collections.
Let's implement an abstraction that wraps an Immutable.js collection, augmenting it with history tracking capabilities:
import { List, Stack } from 'immutable';
// Names of List and Map methods that perform
// persistent changes. These are the methods that
// we want to build history from.
const persistentChanges = [
'set',
'delete',
'deleteAll',
'clear',
'update',
'merge',
...
];
// Where the history for a given collection is stored.
const mutations = new WeakMap();
// Defines proxy behavior for collection instances.
// It's a way to trap method calls and redirect them
// to instances in the mutations map.
const historyHandler = {
get(target, key) {
// Before we do anything, make sure that the
// target collection has a Stack instance in
// the mutations map.
if (!mutations.has(target)) {
mutations.set(
target,
Stack().unshift(target)
);
}
// Get the mutation history for this collection.
const stack = mutations.get(target);
// Check if the caller is calling one of the
// recognized persistent change methods.
if (persistentChanges.includes(key)) {
// Return a function that calls the method in question
// on the most recent stack item.
return (...args) => {
const result = stack.first()[key](...args);
// Store the result as the newest item on the stack.
mutations.set(
target,
stack.unshift(result)
);
// Return the result like normal.
return result;
};
// The caller is calling the undo() method. Remove the
// first item on the stack, if there's more than
// one item.
} else if (key === 'undo') {
return () =>
mutations.set(
target,
stack.count() > 1 ? stack.shift() : stack
);
}
// Not a persistent change method, just call it and
// return the result.
return stack.first()[key];
}
};
// Wraps a List instance with the historyHandler proxy.
const myList = new Proxy(List.of(1, 2, 3), historyHandler);
console.log('myList', myList.toJS());
// -> myList [ 1, 2, 3 ]
myList.push(4);
console.log('push(4)', myList.toJS());
// -> push(4) [ 1, 2, 3, 4 ]
myList.delete(0);
console.log('delete(0)', myList.toJS());
// -> delete(0) [ 2, 3, 4 ]
myList.undo();
console.log('undo()', myList.toJS());
// -> undo() [ 1, 2, 3, 4 ]
myList.undo();
console.log('undo()', myList.toJS());
// -> undo() [ 1, 2, 3 ]
There's a lot going on here, so let's unpack it. We'll start with the end result—an Immutable.js list augmented with history tracking capabilities:
const myList = new Proxy(List.of(1, 2, 3), historyHandler);
console.log('myList', myList.toJS());
// -> myList [ 1, 2, 3 ]
myList.push(4);
console.log('push(4)', myList.toJS());
// -> push(4) [ 1, 2, 3, 4 ]
myList.delete(0);
console.log('delete(0)', myList.toJS());
// -> delete(0) [ 2, 3, 4 ]
myList.undo();
console.log('undo()', myList.toJS());
// -> undo() [ 1, 2, 3, 4 ]
myList.undo();
console.log('undo()', myList.toJS());
// -> undo() [ 1, 2, 3 ]
The Proxy class—introduced to JavaScript in ES2015—is used as a mechanism to intercept anything that's called on the list that it wraps. We're mainly interested in the persistent change methods and the undo() method. As you can see, calling the undo() method on the list removes the most recent state of the collection. Here, we're making three changes, so calling undo() three times puts the list back in its original state.
To store the state of each collection after it has been changed, we're using WeakMap. The collection itself is the key, while the value is a stack of collections. Every time the collection changes, a new version of the collection is added to this stack. We also have a list of methods—these are the persistent change methods about which we care most. If it isn't in this list, then we don't care about it:
const persistentChanges = [
'set',
'delete',
'deleteAll',
'clear',
'update',
'merge',
...
];
const mutations = new WeakMap();
Then we have the proxy handler itself. It only defines a get() method. The idea is that it intercepts any method calls to the collection that you're wrapping. Then, you can decide what to do. You can either do nothing and simply forward the call to the collection, or you can forward the call to the collection and store the resulting new collection in its mutation stack.
The first step is getting the stack, which involves creating one if it doesn't exist:
if (!mutations.has(target)) {
mutations.set(
target,
Stack().unshift(target)
);
}
const stack = mutations.get(target);
Now that we have the mutation stack for the given collection, we're ready to figure out what to do with the requested key. We'll check if the caller wants a persistent change method: persistentChanges.includes(key). If this is the case, then you can return a function that will push the result of the change (a new collection) onto the mutation stack:
return (...args) => {
const result = stack.first()[key](...args);
mutations.set(
target,
stack.unshift(result)
);
return result;
};
You call the method in question from the first instance of this collection in the mutation stack. The result then goes into the stack; so, the next time that you call a persistent change method, it'll be from this new collection.
If the caller is looking for the undo() method, we provide the following implementation from the proxy:
return () =>
mutations.set(
target,
stack.count() > 1 ? stack.shift() : stack
);
This pops the latest version off the mutation stack. For any other method that you try to call on this collection—anything that isn't a persistent change method or the undo() method—you just need to forward the call to the first collection in the mutation stack, since it is the latest version of the collection:
return stack.first()[key];
Is this a perfect solution or the only way to go in implementing history with immutable collections? It sure isn't, but it does what we need it to for now. In the future, you might decide only to keep so many versions of the collections to avoid memory leaks.