13

I'm building an app where actions are performed as the user scrolls down. It would be nice if I could undo those actions as the user scrolls up again, basically turning scrolling into a way to browse through the time line of actions.

Is there a built-in way in Redux to do this? Or would I have to write middleware for this?

Vincent
  • 4,876
  • 3
  • 44
  • 55

4 Answers4

12

Is there a built-in way in Redux to do this? Or would I have to write middleware for this?

Middleware sounds like the wrong idea in this case because this is purely state management concern. Instead you can write a function that takes a reducer and returns a reducer, “enhancing” it with action history tracking along the way.

I outlined this approach in this answer, and it's similar to how redux-undo works, except that instead of storing the state, you can store actions. (Depends on the tradeoffs you want to make, and whether it's important to be able to “cancel” actions in a different order than they happened.)

Community
  • 1
  • 1
Dan Abramov
  • 264,556
  • 84
  • 409
  • 511
  • That's exactly what I ended up doing - although I did indeed store the state. Although it's not important to cancel actions in a different order, memory-wise it would've been nicer to store the actions. However, I have no idea how I would then go about firing them off again from my reducer - do you know how that could've been done? – Vincent Oct 03 '15 at 13:42
  • @Vincent You wouldn't need to fire them again—just recalculate the `present` by running something like `state.actions.reduce(reducer)`. – Dan Abramov Oct 03 '15 at 13:44
  • 3
    Ha, makes sense, thanks. Also, I just noticed you're mr. Redux; thanks a lot for this excellent library! – Vincent Oct 04 '15 at 09:18
4

I believe the idea is not so much "undo" as much as save a reference to the entire state tree each time an action passes through redux.

You would have a history stack made up of the application state at various times.

let history = [state1, state2, state3]

// some action happens

let history = [state1, state2, state3, state4]

// some action happens

let history = [state1, state2, state3, state4, state5]

// undo an action

let history = [state1, state2, state3, state4]

state = state4

To "undo" an action, you just replace the application state with one of the saved states.

This can be made efficient with data structures that support structural sharing, but in development we don't really need to consider resource constraints too much anyway.

Qiming
  • 1,664
  • 1
  • 14
  • 18
  • Right. I am now thinking I will have to write a reducer that responds to the scroll actions to save the current state - only thing is that I don't know yet how to have that reducer both read the part of the state tree that I want to save, and then copy that over to a _different_ part of the state tree. Note that this isn't merely for development; my use case is for actual user interaction. – Vincent Sep 13 '15 at 12:04
  • If a reducer needs access to two different parts of the state tree, it probably means you need a reducer that composes the both of them together – Qiming Sep 13 '15 at 16:28
  • @Vincent Or, that the second state isn't actually fundamental (ie not a source of truth) and can be discarded – Qiming Sep 13 '15 at 16:28
  • Hmm, that last remark makes sense - if I'm keeping a history of the state, I should also be able to just keep a pointer to the current version (or just use the latest addition to the stack). Thanks! – Vincent Sep 14 '15 at 17:11
  • I should maybe add that I went with this and described how I implemented this in more detail at https://vincenttunru.com/Composing-Redux-reducers/ – Vincent Sep 15 '16 at 06:37
  • You want to keep individual history per-reducer to **avoid duplicating** the whole state on every "tiny" *dispatch* call. it's unreasonable to keep enormous amounts of data cloned every time some toggle button is clicked or some timer is constantly fired, triggering a state update – vsync Oct 04 '18 at 07:06
2

I also wanted to create a simple undo functionality, but had already shipped an app with redux-storage that serializes and loads the state for every user. So to keep it backwards-compatible, I couldn't use any solution that wraps my state keys, like redux-undo does with past: [] and present:.

Looking for an alternative, Dan's tutorial inspired me to override combineReducers. Now I have one part of the state: history that saves up to 10 copies of the rest of the state and pops them on the UNDO action. Here's the code, this might work for your case too:

function shouldSaveUndo(action){
  const blacklist = ['@@INIT', 'REDUX_STORAGE_SAVE', 'REDUX_STORAGE_LOAD', 'UNDO'];

  return !blacklist.includes(action.type);
}

function combineReducers(reducers){
  return (state = {}, action) => {
    if (action.type == "UNDO" && state.history.length > 0){
      // Load previous state and pop the history
      return {
        ...Object.keys(reducers).reduce((stateKeys, key) => {
          stateKeys[key] = state.history[0][key];
          return stateKeys;
        }, {}),
        history: state.history.slice(1)
      }
    } else {
      // Save a new undo unless the action is blacklisted
      const newHistory = shouldSaveUndo(action) ?
        [{
          ...Object.keys(reducers).reduce((stateKeys, key) => {
            stateKeys[key] = state[key];
            return stateKeys;
          }, {})
        }] : undefined;

      return {
        // Calculate the next state
        ...Object.keys(reducers).reduce((stateKeys, key) => {
          stateKeys[key] = reducers[key](state[key], action);
          return stateKeys;
        }, {}),
        history: [
          ...(newHistory || []),
          ...(state.history || [])
        ].slice(0, 10)
      };
    }
  };
}


export default combineReducers({
  reducerOne,
  reducerTwo,
  reducerThree
});

For me, this works like a charm, it just doesn't look very pretty. I'd be happy for any feedback if this is a good / bad idea and why ;-)

kadrian
  • 4,761
  • 8
  • 39
  • 61
1

There's no built-in way to do this. but you can get inspired by how redux-dev-tools works (https://github.com/gaearon/redux-devtools). It basically have "time travel" functionality and it work by keep a track of all actions and reevaluating them each time. So you can navigate easily thorough all your changes.

Zied
  • 1,696
  • 12
  • 20