DEV Community

Cover image for React Hook to Allow Undo/Redo
Jeremy
Jeremy

Posted on

React Hook to Allow Undo/Redo

If you're looking to build no-code tools like FormBlob, one must-have feature is the ability to undo and redo actions. Why? Imagine you were working on an image-editing software and you made multiple changes to your canvas. After some time, you realise that what you had before looked much better than what you have now. You would undo your way back until you get to a stage you're satisfied with.

Now, if the software did not have an undo/redo function, you would most probably unleash some flowery language and abandon the software forever.

So how do we implement an undo/redo function and prevent users from abandoning our app?

Prerequisite

If you're not familiar with React Hooks, I suggest you read up about them here. One of the most fundamental hooks is React's built-in useState hook. This allows you to store component state within a variable and manage it as required. In this tutorial, we will be writing a hook that extends the useState hook to allow for undo/redo functionality.

The Code

Let's start with the code and then I'll explain below.

import { useMemo, useState } from "react";
// If you're only working with primitives, this is not required
import isEqual from "lodash/isEqual";

export default function useUndoableState(init) {
  const [states, setStates] = useState([init]); // Used to store history of all states
  const [index, setIndex] = useState(0); // Index of current state within `states`

  const state = useMemo(() => states[index], [states, index]); // Current state

  const setState = (value) => {
    // Use lodash isEqual to check for deep equality
    // If state has not changed, return to avoid triggering a re-render
    if (isEqual(state, value)) {
      return;
    }
    const copy = states.slice(0, index + 1); // This removes all future (redo) states after current index
    copy.push(value);
    setStates(copy);
    setIndex(copy.length - 1);
  };

  // Clear all state history
  const resetState = (init) => {
    setIndex(0);
    setStates([init]);
  };

  // Allows you to go back (undo) N steps
  const goBack = (steps = 1) => {
    setIndex(Math.max(0, Number(index) - (Number(steps) || 1)));
  };

  // Allows you to go forward (redo) N steps
  const goForward = (steps = 1) => {
    setIndex(Math.min(states.length - 1, Number(index) + (Number(steps) || 1)));
  };

  return {
    state,
    setState,
    resetState,
    index,
    lastIndex: states.length - 1,
    goBack,
    goForward,
  };
}
Enter fullscreen mode Exit fullscreen mode

Concept

As with useState, useUndoableState accepts only 1 argument, the initial value. Behind the scenes, the hook uses two main variables to determine state - index (number) and states (array). states stores the historical values of the state while index determines current state by indicating the current position in the array.

You may navigate through historical states by using the goBack and goForward functions emitted by the hook. However, if you make a call to setState and index is not at the end of the states array, all states after index is erased and index will go back to the end of the states array. In other words, once you call setState, you can no longer redo.

The following table attempts to provide a more detailed explanation of the object returned by the hook:

Prop Type Usage Description
state any Current state, initialised with argument passed
setState func setState(value) Sets state to value. All values after current index is erased
resetState func resetState(value) Deletes historical states and resets to value
index number The current index in the states array
lastIndex number The last index in the states array. Can be used to determine if can goForward. canGoForward = index < lastIndex
goBack func goBack(2) Goes back the number of steps passed
goForward func goForward(3) Goes forward the number of steps passed

Usage

import React from "react";
import useUndoableState from "path/to/hook";

const init = { text: "The quick brown fox jumps over the lazy dog" };

export default function Document() {
  const {
    state: doc,
    setState: setDoc,
    resetState: resetDoc,
    index: docStateIndex,
    lastIndex: docStateLastIndex,
    goBack: undoDoc,
    goForward: redoDoc
  } = useUndoableState(init);

  const canUndo = docStateIndex > 0;
  const canRedo = docStateIndex < docStateLastIndex;

  return (
    <div style={{ display: "block" }}>
      <textarea
        style={{ margin: "16px" }}
        onChange={(event) => setDoc({ text: event.target.value })}
        rows="5"
        value={doc.text}
      />
      <div>
        <button
          onClick={() => undoDoc()}
          disabled={!canUndo}
          style={{ marginRight: "8px" }}
        >
          Undo
        </button>
        <button
          onClick={() => redoDoc()}
          disabled={!canRedo}
          style={{ marginRight: "8px" }}
        >
          Redo
        </button>
        <button onClick={() => resetDoc(init)}>Reset</button>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Concluding Remarks

With undo/redo functionality, FormBlob is one of a select few no-code form builders that gives you the flexibility to edit your forms without the fear of losing a previous state. As a no-code tool, FormBlob allows anyone to build and publish stunning forms and surveys in 2 minutes. Try it free today!

Discussion (5)

Collapse
lukeshiru profile image
Luke Shiru

Instead of using lodash, try using klona and dequal by lukeed. Both super lightweight and fast, and without the need to install a gigantic library like lodash.

I would also recommend to make a simple copy by just doing [...state] instead of using either deepClone or klona because that's good enough if you're doing react correctly (avoiding mutations). And instead of doing that nasty index mutation, ideally you should use something like slice for that.

Cheers!

Collapse
jeremyling profile image
Jeremy Author

Yes, definitely. Use any library you prefer as long as the methods are equivalent. Tree shake or import only what you need for production and you're good.

Thank you for the second point! I've had use for deep copies elsewhere in the project and admittedly got lazy here. There isn't a need for a deep copy here since there's no need to mutate the copy contents. Have edited the code to use your suggestions.

For those who read this and lack context, this was the original code in the setState method:

const copy = cloneDeep(states); // Use lodash cloneDeep to get a deep copy
copy.length = index + 1; // This is to remove all future (redo) states after current index

Following LUKESHIRU's suggestion I changed that to:

const copy = states.slice(0, index + 1); // This removes all future (redo) states after current index

Collapse
zyabxwcd profile image
Akash • Edited on

cool. at the beginning it seemed like a very complicated feature to implement. will this implementation work efficiently for more complicated tasks where the state is not just a simple text? have you tried something more complex with this implementation? and are you aware of any alternative implementations?

Collapse
jeremyling profile image
Jeremy Author

Yes I use this in production for a drag and drop form builder to store the historical state of the form for undo/redo purposes. The form in this case is typically a deeply nested object storing serializable and non-serializable (Dates, functions, blobs, files, HTML elements) types.

I'm sure there are many implementations for undo/redo functionality. As the saying goes, there's a hook for everything. If there are further requirements you need (maybe diffing), you can extend this or search for other solutions.