For a while now I've been wracking my brain as to how you would implement undo / redo in Redux with server interactions (via ajax).
I've come up with a solution using a command pattern where actions are registered with an execute
and undo
method as Commands, and instead of dispatching actions you dispatch commands. The commands are then stored in a stack and raise new actions where required.
My current implementation uses middleware to intercept dispatches, test for Commands and call methods of the Command and looks something like this:
let commands = [];
function undoMiddleware({ dispatch, getState }) {
return function (next) {
return function (action) {
if (action instanceof Command) {
// Execute the command
const promise = action.execute(action.value);
commands.push(action);
return promise(dispatch, getState);
} else {
if (action.type === UNDO) {
// Call the previous commands undo method
const command = commands.pop();
const promise = command.undo(command.value);
return promise(dispatch, getState);
} else {
return next(action);
}
}
};
};
}
const UNDO = 'UNDO';
function undo() {
return {
type: UNDO
}
}
function add(value) {
return (dispatch, getState) => {
const { counter } = getState();
const newValue = counter + value;
return new Promise((resolve, reject) => {
resolve(newValue); // Ajax call goes here
}).then((data) => {
dispatch(receiveUpdate(data));
});
}
}
function sub(value) {
return (dispatch, getState) => {
const { counter } = getState();
const newValue = counter - value;
return new Promise((resolve, reject) => {
resolve(newValue); // Ajax call goes here
}).then((data) => {
dispatch(receiveUpdate(data));
});
}
}
class Command {
execute() {
throw new Error('Not Implemented');
}
undo() {
throw new Error('Not Implemented');
}
}
class AddCommand extends Command {
constructor(value) {
super();
this.value = value;
}
execute() {
return add(this.value);
}
undo() {
return sub(this.value);
}
}
const store = createStoreWithMiddleware(appReducer);
store.dispatch(new AddCommand(10)); // counter = 10
store.dispatch(new AddCommand(5)); // counter = 15
// Some time later
store.dispatch(undo()); // counter = 10
(a more complete example here)
There are several issues I've found with my current approach:
UNDO
command type.My question then, is can anyone suggest a better way of implementing this functionality within Redux?
The biggest flaws I see right now are the commands being added before actions have completed, and how it would be difficult to add optimistic updates to the mix.
Any insight is appreciated.
If “UNDO” string is encountered, pop the top element from Undo stack and push it to Redo stack. If “REDO” string is encountered, pop the top element of Redo stack and push it into the Undo stack.
To undo an action, press Ctrl + Z. To redo an undone action, press Ctrl + Y. The Undo and Redo features let you remove or repeat single or multiple typing actions, but all actions must be undone or redone in the order you did or undid them – you can't skip actions.
Redux History lets you sync redux with rackt history, giving you access to the current location object in your reducers, optionally including URL queries. It's meant to be a dead simple enhancement for redux, it will also work nicely with react-router.
Redux is a tiny library, but its contracts and APIs are carefully chosen to spawn an ecosystem of tools and extensions, and the community has created a wide variety of helpful addons, libraries, and tools.
Debating further on the Immutable based implementation suggested by @vladimir-rovensky...
Immutable works very well for client side undo-redo management. You can simply store last "N" instances of the immutable state either yourself or using a library like immstruct which does it for you. It doesn't result in memory overhead due to instance sharing built into immutable.
However, syncing the model every-time with the server may be costly if you wish to keep it simple, because you would need to send the entire state to server every time it is modified on client. Depending on the state size this will not scale well.
A better approach will be to send only the modifications to the server. You need a "revision" header in your state when you send it initially to the client. Every other modification to the state done on client should record only the diffs and send them over to the server with the revision. The server can execute the diff operations and send back a new revision and checksum of the state following the diffs. The client can verify this against current state checksum and store the new revision. The diffs can also be stored by the server tagged with the revision and checksum in its own undo history. If an undo is desired on the server, the diffs can be reversed to obtain the state and checksum checks can be performed. A diffing library for immutable which I came across is https://github.com/intelie/immutable-js-diff. It creates RFC-6902 style patches which you can execute using http://hackersome.com/p/zaim/immpatch on the server state.
Advantages-
You've come up with the best possible solution, yes Command Pattern is the way to go for async undo/redo.
A month ago I realised that ES6 generators are quite underestimated and may bring us some better use cases than calculating fibonacci sequence. Async undo/redo is a great example.
In my opinion, the principle problem with your approach is usage of classes and ignoring failing actions (optimistic update is too optimistic in your example). I tried to solve the problem using async generators. The idea is pretty simple, AsyncIterator
returned by async generator can be resumed when undo is needed, this basically means that you need to dispatch
all intermediate actions, yield
the final optimistic action and return
the final undo action. Once the undo is requested you can simply resume the function and execute everything what is necessary for undo (app state mutations / api calls / side effects). Another yield
would mean that the action hasn't been successfully undone and user can try again.
The good thing about the approach is that what you simulated by class instance is actually solved with more functional approach and it's function closure.
export const addTodo = todo => async function*(dispatch) {
let serverId = null;
const transientId = `transient-${new Date().getTime()}`;
// We can simply dispatch action as using standard redux-thunk
dispatch({
type: 'ADD_TODO',
payload: {
id: transientId,
todo
}
});
try {
// This is potentially an unreliable action which may fail
serverId = await api(`Create todo ${todo}`);
// Here comes the magic:
// First time the `next` is called
// this action is paused exactly here.
yield {
type: 'TODO_ADDED',
payload: {
transientId,
serverId
}
};
} catch (ex) {
console.error(`Adding ${todo} failed`);
// When the action fails, it does make sense to
// allow UNDO so we just rollback the UI state
// and ignore the Command anymore
return {
type: 'ADD_TODO_FAILED',
payload: {
id: transientId
}
};
}
// See the while loop? We can try it over and over again
// in case ADD_TODO_UNDO_FAILED is yielded,
// otherwise final action (ADD_TODO_UNDO_UNDONE) is returned
// and command is popped from command log.
while (true) {
dispatch({
type: 'ADD_TODO_UNDO',
payload: {
id: serverId
}
});
try {
await api(`Undo created todo with id ${serverId}`);
return {
type: 'ADD_TODO_UNDO_UNDONE',
payload: {
id: serverId
}
};
} catch (ex) {
yield {
type: 'ADD_TODO_UNDO_FAILED',
payload: {
id: serverId
}
};
}
}
};
This would of course require middleware which is able to handle async generators:
export default ({dispatch, getState}) => next => action => {
if (typeof action === 'function') {
const command = action(dispatch);
if (isAsyncIterable(command)) {
command
.next()
.then(value => {
// Instead of using function closure for middleware factory
// we will sned the command to app state, so that isUndoable
// can be implemented
if (!value.done) {
dispatch({type: 'PUSH_COMMAND', payload: command});
}
dispatch(value.value);
});
return action;
}
} else if (action.type === 'UNDO') {
const commandLog = getState().commandLog;
if (commandLog.length > 0 && !getState().undoing) {
const command = last(commandLog);
command
.next()
.then(value => {
if (value.done) {
dispatch({type: 'POP_COMMAND'});
}
dispatch(value.value);
dispatch({type: 'UNDONE'});
});
}
}
return next(action);
};
The code is quite difficult to follow so I have decided to provide fully working example
UPDATE: I am currently working on rxjs version of redux-saga and implementation is also possible by using observables https://github.com/tomkis1/redux-saga-rxjs/blob/master/examples/undo-redo-optimistic/src/sagas/commandSaga.js
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With