Implementing undo / redo in Redux
Asked Answered
C

3

16

Background

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:

Middleware

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);
        }
      }
    };
  };
}

Actions

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));
        });
    }
}

Commands

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);
    }
}

App

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:

  • Due to implementing via middleware, only one stack may exist for the entire application.
  • Cannot customise UNDO command type.
  • Creating a Command to call actions which in turn return promises seems very convoluted.
  • Commands are added to the stack before the action completes. What happens for errors?
  • As commands are not in state, cannot add is_undoable functionality.
  • How would you implement optimistic updates?

Help

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.

Camellia answered 16/11, 2015 at 23:59 Comment(3)
Had a chance to look at github.com/ForbesLindesay/redux-optimist yet?Imaimage
I have indeed (nice little library). I can see how I could use it to help with my optimistic issues, but like redux-undo it only deals with reversing actions inside reducers. With async undo I'm struggling where and how to store the command stack within state, as this seems like the most logical place for it to exist in this type of application.Darladarlan
It seems that the often touted claim "undo/redo is easy with redux" is a half-truth (at best). I find myself in the same situation as you, needing to sync server state with a DB/REST API. Curiously, none of the redux docs or associated undo/redo libs even mention this extremely common use case. I would venture that most setups have to deal with this. I too am looking for a way to easily undo/redo changes to my store and easily update my server. My current thought is to keep track of actions & action arguments instead of state changes. That seems a bit similar to your approach in concept.Audiovisual
C
4

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-

  • Simplified client architecture. Server sync up is not scattered all over the client code. It can be initiated from your stores whenever client state changes.
  • Simple undo/redo syncs with server. No need to handle different client state changes individually, aka no command stacks. The diff patch tracks almost any kind of state changes in a consistent fashion.
  • Server side undo history without major transaction hits.
  • Validation checks ensure data consistency.
  • Revision header allows for multi-client simultaneous updates.
Compassion answered 16/12, 2015 at 12:26 Comment(7)
I decided to give this idea a try, to better weigh up what the implementation would look like. I created a demo here. In this demo I send patches to an express powered API that runs the diffs generated by the client against a local list. This works pretty well for this simple use case. If my data was stored in a database though, I imagine I would need to parse the operations in order to create queries. In this scenario, is there any benefit to using patch operations over explicit actions?Darladarlan
Another concern I have is related objects. Say I have a list of todos, and I allow adding comments to them: In a list this is trivial to apply patches. But for a database powered dataset it seems like parsing the patches becomes ever more complex. Your idea, has certainly given me pause for my pure client side approach. I do like the simplified client side logic this advocates.Darladarlan
Thanks for giving it a spin. I haven't checked the demo yet, but my initial thoughts.... Aren't you first taking a complete snapshot of the document from db on the server? Like when you first fetch and send the user todos from the server. The patches are meant to be used only between client and server. The server can always update the entire document to the db. For small to moderate document sizes, the savings made by selected edits is not much over a full update. You'd note that the patcher design is beneficial when client is making frequent and small syncs with the server.Compassion
And you can look at this for mongoDB patch updates. An important aspect of the patches are that they are a standard and should see support for most popular JSON data bases in one way or the other.Compassion
@Ashley'CptLemming'Wilson how did you get on with this after? What approach did you settle on? Your situation seems very similar to mine, so interested how you got onHetman
Unfortunately I never got to a solution I was comfortable with and ended up dropping the feature. I still secretly hope a library / article will pop up one day and suggest a way forward to performing async undo/redo in redux/react.Darladarlan
@Ashley'CptLemming'Wilson Sorry, missed this due to lack of notification. Thanks for getting back to me. That's a shame, seems like it's still an unsolved problem then :(Hetman
S
2

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

Sievers answered 21/12, 2015 at 10:41 Comment(3)
This is an intriguing change (never used async functions or generators in javascript). Giving the code a once over, I can't see how you'd implement redo? With the undo logic buried inside the original action, you could add the redo after the undo logic but then you'd never be able to call undo again. Thanks for setting up the demo though, very helpful indeed!Darladarlan
Instead of popping the command out of command log we could simply implement redo by using strategy described in redux docs. Redo would basically mean re-triggering the original action again.Oligochaete
I've just updated the answer with implementation (including Undo/Redo/optimistic updates) using rxjs github.com/tomkis1/redux-saga-rxjs/blob/master/examples/…Oligochaete
E
0

Not sure I understand your use case completely, but in my opinion the best way to go about implementing undo/redo in ReactJS is via an immutable model. Once your model is immutable, you can easily maintain a list of states as they change. Specifically, you need an undo list and a redo list. In your example it would be something like:

  1. Starting counter value = 0 -> [0], []
  2. Add 5 -> [0, 5], []
  3. Add 10 -> [0, 5, 15], []
  4. Undo -> [0, 5], [15]
  5. Redo -> [0, 5, 15], []

The last value in the first list is the current state (that goes into the component state).

This is a much simpler approach then Commands, since you don't need to define undo/redo logic separately for every action you want to perform.

If you need to synchronize state with the server, you can do that too, just send your AJAX requests as part of the undo/redo operation.

Optimistic updates should also be possible, you can update your state immediately, then send your request and in its error handler, revert to state prior to the change. Something like:

  var newState = ...;
  var previousState = undoList[undoList.length - 1]
  undoList.push(newState);
  post('server.com', buildServerRequestFrom(newState), onSuccess, err => { while(undoList[undoList.length-1] !== previousState) undoList.pop() };

In fact I believe you should be able to achieve all the goals you listed with this approach. If you feel otherwise, could you be more specific about what you need to be able to do?

Epirogeny answered 9/12, 2015 at 15:27 Comment(5)
As I understand your suggestion; You suggest firing a create action to the server, then to 'undo' the action you'd fire a sync action to the server? Going down this route, wouldn't you have to send your entire application state to the server and it ask it to 'figure out' what it needs to do? Wouldn't you instead expect the client to send a delete command to the server?Darladarlan
I'd have the server persist the whole state, as an idempotent operation. This could be problematic if your state is really big or you expect a lot of traffic, in that case you'd have to either compute a diff between the two states (old, current) either on the client or on the server, or maintain the operation you made for every state in the list. The immutable update requires a special operation anyway (like the React's update method), so you could wrap that in your own API and do it there;Epirogeny
The server side solution seems much more coupled to the use of the data than I'm comfortable with; Ideally I'd see an API giving out data consumed by a front end client that chooses to implement undo / redo. I guess I was hoping more for a client side solution to the problem, rather than shifting all the logic to the server.Darladarlan
Maybe you could create your server requests from the definitons of the immutable updates that happen in your model, e.g. you might have an operation that adds a user: var newModel = update(model, { users: { $push: newUser } } You might be able to create your server request from that second argument. For undo you'd just use the reverse operations (pop instead of push). I'd definitely at least investigate this direction (immutable model), since it is generic in that you don't need a new command with undo/redo logic for every operation.Epirogeny
Also, if you choose to make your server API not idempotent (sending add/remove rather than the whole collection), remember that the ordering of the operations matters, so you'll have to have some logic on the server that handles any out-of-order messages properly.Epirogeny

© 2022 - 2024 — McMap. All rights reserved.