Thursday, January 28, 2021

Implementing Undo-Redo Functionality in Redux using Immer

Got a requirement to implement an undo-redo functionality in your frontend application that is built using Redux, and if you are looking for a cleaner approach, then keep reading!

What is Immer?

To put it in simple terms, Immer is a library that allows you to efficiently work with immutable data, in such a way that you won’t even feel you are working with immutable data. If you are not familiar with Immer already, I highly recommend you check out their official page here before you continue.

Immer brings in a feature called Patches. That allows us to implement many cool features – one of them being Undo-Redo.

So, What are Patches?

Immer records all the changes that are performed on the draft object and generate an array of JSON objects indicating what has changed. These arrays are called Patches. Immer also produces inverse patches, which are patches that need to be applied if you want to go back to the previous state.

Example:
Let’s say we have a state like this, and you want to update the age to 40, this is how you would do it normally with Immer.

let state = {
    name: "Micheal",
    age: 32
};

state = produce(state, draft => {
    draft.age = 40;
});

To get our hands on the generated patches, we can use the third argument of the produce function, which is a callback function that will receive both patches and inverse patches.

state = produce(state, draft => {
    draft.age = 40;
}, (patches, inversePatches) => {
    console.log(patches); 
    // [ { op: 'replace', path: [ 'age' ], value: 40 } ]

    console.log(inversePatches); 
    // [ { op: 'replace', path: [ 'age' ], value: 32 } ]
});

These patches are ideal fit to implement an Undo-Redo in Redux. That’s exactly what we will do in the following section.

Implementing Undo-Redo in Redux using Immer Patches

For illustration purpose, we will create a very simple reducer for a Todo List like so:

import produce from 'immer';
import { ADD_TODO, REMOVE_TODO } from '../actions/todos';

const initialState = {
  todos: [
    {
      desc: "Writing an article"
    },
    {
      desc: "Dont use todo application for example"
    }
  ]
}

export default function(state = initialState, action) {
  return produce(state, draft => {
    switch (action.type) {
      case ADD_TODO:
        draft.todos.push(action.payload.todo);
        break;

      case REMOVE_TODO:
        draft.todos.splice(action.payload.index, 1);
        break;
    }
  });
}

To add the undo-redo in Redux, we will first create a hash and store the patches and inverse patches.

const changes = {};
let currentVersion = -1;

export default function(state = initialState, action) {
  return produce(state, draft => {
    // ... code removed for brevity
  }, (patches, inversePatches) => {
    currentVersion++;

    changes[currentVersion] = {
      redo: patches,
      undo: inversePatches
    }
  });
}

We may not want to allow an infinite number of undo-redo as it requires us to store an infinite number of patches and inverse patches. At some point, it will put too much load on the browser. So we can put a hard limit on the number of undo/redo like below:

const changes = {};
let currentVersion = -1;
const noOfVersionsSupported = 100;

export default function(state = initialState, action) {
  return produce(state, draft => {
    // ... code removed for brevity
  }, (patches, inversePatches) => {
    currentVersion++;

    changes[currentVersion] = {
      redo: patches,
      undo: inversePatches
    }

    delete changes[currentVersion + 1];
    delete changes[currentVersion - noOfVersionsSupported];
  });
}

Also, we don’t want to record changes triggered by every action. So we will filter out patches, and inverse patches based on the action received.

import { ADD_TODO, REMOVE_TODO } from '../actions/todos';

const changes = {};
let currentVersion = -1;
const noOfVersionsSupported = 100;
const undoableActions = [ADD_TODO, REMOVE_TODO];

export default function(state = initialState, action) {
  return produce(state, draft => {
    // ... code removed for brevity
  }, (patches, inversePatches) => {
    if (undoableActions.indexOf(action.type) !== -1) {
      currentVersion++;

      changes[currentVersion] = {
        redo: patches,
        undo: inversePatches
      }

      delete changes[currentVersion + 1];
      delete changes[currentVersion - noOfVersionsSupported];
    }
  });
}

Now that we have all our patches and inverse patches ready, all that’s left for us to do is apply these patches when the user performs an undo-redo action. To apply the patches, we can use the applyPatches function provided by Immer.

import produce, { applyPatches } from 'immer';
import { ADD_TODO, REMOVE_TODO, UNDO, REDO } from '../actions/todos';

// ... code removed for brevity

export default function(state = initialState, action) {
  return produce(state, draft => {
    switch (action.type) {
      // ... code removed for brevity

      case UNDO:
        return applyPatches(state, changes[currentVersion--].undo);

      case REDO:
        return applyPatches(state, changes[++currentVersion].redo);
    }
  }, (patches, inversePatches) => {
    // ... code removed for brevity
  });
}

Finally, we should enable the undo-redo buttons only when we have a patch available for doing it. To do that, we will add two more fields to our redux state – canUndo and canRedo, and we will set these flags based on the following rules:

  1. When we receive an undoable action – we can always undo but cannot redo anymore. That’s how undo-redo works in Google Docs.
  2. Whenever you perform an undo – you can always redo and keep doing undo as long as we have a patch for it.
  3. Whenever you redo – you can always undo and keep doing redo as long as we have a patch for it.
export default function(state = initialState, action) {
  return produce(state, draft => {
    switch (action.type) {
      // ... code removed for brevity

      case UNDO:
          return produce(
            applyPatches(state, changes[currentVersion--].undo),
            newDraft => {
              newDraft.canUndo = changes.hasOwnProperty(currentVersion);
              newDraft.canRedo = true;
            }
          );

        case REDO:
          return produce(
            applyPatches(state, changes[++currentVersion].redo),
            newDraft => {
              newDraft.canUndo = true;
              newDraft.canRedo = changes.hasOwnProperty(currentVersion + 1);
            }
          );
    }
    if (undoableActions.indexOf(action.type) !== -1) {
      draft.canUndo = true;
      draft.canRedo = false;
    }
  }, (patches, inversePatches) => {
    // ... code removed for brevity
  });
}

applyPatches returns a new state, to update the flags immutably on the new state we will pass it to produce and then perform the update on the newDraft.

Now we have a full implementation of undo-redo in Redux with a configurable limit and actions. I hope it was helpful to you, share your thoughts on the comment sections.

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Hot Topics

Netflix MOD APK 7.85.1 (Premium Unlocked) [Working]

Are you looking for Netflix Mod APK Premium Version? This article brings you the latest version of Netflix Mod APK with unlimited features. Netflix is...

How to Watch deleted YouTube Videos

Ever watched a video you liked a lot that you had to bookmark the URL and when you return to see that video for...

How to Remove Apple ID from iPhone/iPad without Password?

Apple has done a good job of making its newer iPhone models safe and the same goes with recent iOS updates. As important as...

Related Articles

Netflix MOD APK 7.85.1 (Premium Unlocked) [Working]

Are you looking for Netflix Mod APK Premium Version? This article brings you the latest version of Netflix Mod APK with unlimited features. Netflix is...

How to Watch deleted YouTube Videos

Ever watched a video you liked a lot that you had to bookmark the URL and when you return to see that video for...

How to Remove Apple ID from iPhone/iPad without Password?

Apple has done a good job of making its newer iPhone models safe and the same goes with recent iOS updates. As important as...