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 w
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