Jul 09, 2022

Implement "useUndoRedo" hook

React undo redo

I'm pretty sure you have clicked a lot of Undos and Redos in your life - I know I have. Both of these functions are pretty important in a workflow as they allow us to travel back and forth in time and to fix potential mistakes.

Have you ever wondered what an undo-redo implementation would look like in a computer program? I'm sure there are myriad implementations out there but the fundamentals are pretty much the same:

  1. A mechanism to store the present value accompanied with the past and future values.
  2. An algorithm that will appropriately alter the state according to the undo, redo and set action.

Based on the two points above, I believe that a Reducer perfectly fits the use case. It's a function which takes two arguments - the current state and an action - and returns based on both arguments a new state.

Let's try and think how we can utilize a reducer in order to accomplish an undo/redo mechanism.

Point 1: We need to store the past and future values along with the present value.

One way we can accomplish that is by storing the past, present and future values inside an object. Note that past and future values need to be an array in order to travel back and forward anywhere in time.

const state = {
    past: [...] // past values
    present: ... // present value
    future: [...] // future values
}

Point 2: An algorithm for each of the undo, redo and set actions.

For the sake of explaining the algorithm better, let's assume that our counter's state looks like this at some point in the application:

const state = {
    past: [0, 1, 2, 3]  
    present: 4
    future: [5, 6]  
}

Undo Action

In an undo action, we need to look into the past but we mustn't lose track of the present. So:

  • Past: The last value of the past is removed from the array.
  • Present: It takes the last value from the past (which is removed as mentioned above).
  • Future: We append the present value to the start of the future array.

So, after an undo action our state would become:

const state = {
    past: [0, 1, 2]  
    present: 3
    future: [4, 5, 6]  
}

Redo Action

In a redo action, we need to look into the future. Hence:

  • Past: We push the present value to the end of the past array.
  • Present: The first value of the future array now becomes the present value.
  • Future: We remove the first element of the future array (which now represents the present value).
const state = {
    past: [0, 1, 2, 3]  
    present: 4
    future: [5, 6]  
}

Set Action

How would the state need to be adjusted when the set action takes place? E.g when the counter is incremented or decremented?

  • Past: The (past) present value now belongs to the past. So we push the value to the past array.
  • Present: It's being set with the new value.
  • Future: The future is cleared.
const state = { 
    past: [0, 1, 2, 3, 4]  
    present: 5
    future: []
}

useUndoRedo: React hook implementation

Take a look at this CodeSandbox: https://codesandbox.io/s/react-undo-redo-c3x8pw

By utilizing the useReducer hook we're able to create a straightforward useUndoRedo custom hook which looks like setState but provides undo-redo mechanism along with the ability to actually see the past and future values of the state.

React undo redo demo

The code of the useUndoRedo hook is shown below.

// use-undo-redo.js

import { useReducer } from "react";

const SET_STATE = "SET_STATE";
const UNDO = "UNDO";
const REDO = "REDO";

const reducerWithUndoRedo = (state, action) => {
  const { past, present, future } = state;

  switch (action.type) {
    case SET_STATE:
      return {
        past: [...past, present],
        present: action.data,
        future: []
      };
    case UNDO:
      return {
        past: past.slice(0, past.length - 1),
        present: past[past.length - 1],
        future: [present, ...future]
      };
    case REDO:
      return {
        past: [...past, present],
        present: future[0],
        future: future.slice(1)
      };
    default:
      throw new Error();
  }
};

const useUndoRedo = (initialState = {}) => {
  const [state, dispatch] = useReducer(reducerWithUndoRedo, {
    past: [],
    present: initialState,
    future: []
  });
  const { past, present, future } = state;

  const setState = (newState) => dispatch({ type: SET_STATE, data: newState });
  const undo = () => dispatch({ type: UNDO });
  const redo = () => dispatch({ type: REDO });
  const isUndoPossible = past && past.length > 0;
  const isRedoPossible = future && future.length > 0;

  return {
    state: present,
    setState,
    undo,
    redo,
    pastStates: past,
    futureStates: future,
    isUndoPossible,
    isRedoPossible
  };
};

export default useUndoRedo;

The End

I hope you found the article informative.

See you soon! 🙂

Loading Comments...