DEV Community

Sebastian Sosa
Sebastian Sosa

Posted on

Create a timed undo feature capable of handling multiple simultaneous undos.

Preface

Kuoly undo showcase

The undo functionality is sparsely represented in products such as Gmail (after email has been "sent") and on Google Docs (when deleting a document). Its absence is due to the fact that in most cases its value does not justify the effort to create it. For as subtle a feature as it is we may take for granted its relatively sophisticated implementation. I know I did. This article will shine a light on this elusive feature and guide you to making one of your own.

Initial approach

When the undo functionality was first proposed to be implemented into Kuoly, I did not think much of it and believed it would be a simple _timeout _function.

Had a really #productive time rolling through tasks. Moved on to the next task "undo" feature for the client, #np I thought, I don't need to #git branch I thought. 4 hours later, I was soooo wrong 😭.https://t.co/2jKePaFGLQ pic.twitter.com/YfnxFFPbno

— Sebastian Sosa (@Sebasti54919704) February 3, 2022

As I expressed in the above tweet I was very wrong.

I would come to find out that this problem is as esoteric as it is complex (a predictable relationship in retrospect). I visited many links and forums seeking a solution. For the most part the solutions came short of what we needed: “A timed undo feature capable of supporting multiple undos simultaneously”.

I had no option but to create my own solution.

A load of undos

Everything you need for this guide.

Multiple solutions

The undo feature can be implemented in various ways such as handling it fully on client or designing a system on the backend.

We adamantly refrained from engaging in the back-end approach as it would involve (we suspected) significantly more work than setting it up on the client. It would also make the backend a lot less focused, and therefore raising the question on whether or not to create a new backend to handle this. Needless to say, making that option a lot less palatable.

When it came time to implement it we quickly decided on handling it on the client. Given that it would run on client _timeout _would be required. Consequently, I tried multiple approaches with _timeout _at its core:

Unsuccessful _timeout _approach

Created a state list containing the items to be deleted (unless restored with undo). Then the _timeout _would be initialized with the callback of deleting the item if it still existed on the state.
The undo would simply remove it from the state.

It would look something like this
import React from "react";

// array of objects marked for deletion
const [markedForDelteion, setMarkedForDeletion] = useState([])

// when item marked to be deleted
const deleteThisItem = (itemId) => {
  const deleteItem = () => { ... }
  const timeoutId = setTimeout(() => {
    deleteItem()
  }, 5000)
  setMarkedForDeletion([
    ...markedForDelteion,
    {
      itemId,
      timeoutId,
    }
  ])
}

// if item is restored
const undoDelete = (itemId) => {
  const item = markedForDeletion.find(item => item.itemId === itemId)
  if (item) {
    clearTimeout(item.timeoutId)
    setMarkedForDeletion(markedForDeletion.filter(item => item.itemId !== itemId))
  }
}
Enter fullscreen mode Exit fullscreen mode

This revealed a fundamental issue with utilizing _timeout _as the centerpiece of the solution. As it turns out, _timeout _follows a closure pattern where all references within the _timeout _are set on initialization. Not to be updated regardless of the state.

That fact eliminated the possibility utilizing a _timeout _that depended on an array state.

Successful (but limited) _timeout _option

Alternatively we found a solution that works when the state is no more complex than a null or a variable. Meaning that the solution works if there is only one undo object.

The solution would look something like this

import React from "react";

// object marked for deletion
const [markedForDelteion, setMarkedForDeletion] = useState(null)

// when item marked to be deleted
const deleteThisItem = (itemId) => {
  const deleteItem = () => { ... }
  const timeoutId = setTimeout(() => {
    deleteItem()
  }, 5000)
  setMarkedForDeletion({
    itemId,
    timeoutId,
  })
}

// if item is restored
const undoDelete = (itemId) => {
  const { timeoutId } = markedForDelteion
  clearTimeout(timeoutId)
  setMarkedForDeletion(null)
}

Enter fullscreen mode Exit fullscreen mode

This was too limited as we needed the undo functionality to support multiple objects.

Solution

After my several failed attempts at solving it, I had to abandon my dependence on timeout. Paired with that, I would also consider using more state hooks. The combination of these two ideas would lead me to the solution.

Side notes

This feature depends heavily on the local state. My local state was handled by the Apollo library. Nevertheless these concepts and integrations apply regardless of what state management system you are using.)

My solution in addition to the aforementioned requirements also handled cascading objects. I will omit that from this article as it is highly specific and will be of no use to the majority of readers. If you’re interested in learning how that is handled, feel free to let me know and i'll make a follow up article. Otherwise, you may visit the links provided where that intricacy is included.

State variables

(variable types)

There are two key state variables required for our undo to work:

The first variable will hold all of the objects the user has set to be deleted.

type MarkedForDeletion = {
  id: string; // Which object is this
  text: string; // The unique undo text to be displayed for this object
  timeout: any; // The id of the timeout (more on this later)
  restoreData: any; // Data for the object to be restored
};
Enter fullscreen mode Exit fullscreen mode

Then the following variable will serve as a notification to the client that one of the items in MarkedForDeletion should be addressed.

type RemoveMFD = {
  id: string; // The id of the object to be addressed
  isUndo: boolean; // Weather the object is to be deleted or restored (undo)
} | null;
Enter fullscreen mode Exit fullscreen mode

Create a function for deletion

(function) (example usage)

You will want to create a function that will handle the various steps involved in the deletion process.

First you initialize the function with the necessary variables.

export const handleDeletion = (
  id: string, // Identifier for the object 
  deletionMutation: () => void, // The function which will be executed if the object is deleted
  textField?: string, // Text to display with the undo notification
  setRemoveMFD?: (value: RemoveMFD) => void, // Variable to mutate the state of removeMFD
  markedForDeletion?: MarkedForDeletion[], // markedForDeletion state variable
  setMarkedForDeletion?: (value: MarkedForDeletion[]) => void, // Variable to mutate the state of markedForDeletion
) => {
  …
}
Enter fullscreen mode Exit fullscreen mode

Within this function the first thing you will want to do is gather the data for the object if it is to be restored.

{
  const itemData = retrieveItemData(id); // Will return all of the data for the item to be deleted
  // Could look like the following
  // {
  //   id: "123",
  //   message: "Hello",
  // }
}
Enter fullscreen mode Exit fullscreen mode

Afterwards, you will set the _timeout _which will notify the client that the item is being deleted when the time has elapsed.

{
…
    const deleteTimeout = setTimeout(() => {
      deletionMutation(); // execute function to delete the object
      setRemoveMFD({ id: cacheId, isUndo: false }); // Notify the client that the object is being deleted
    }, 5000); // timeout will execute after 5 seconds
}
Enter fullscreen mode Exit fullscreen mode

Then, you append the object to the markedForDeletion list.

{
…
    setMarkedForDeletion([
      ...markedForDeletion,
      {
        id: cacheId,
        text: `Deleted object with message “${itemData.message}” deleted`,
        timeout: deleteTimeout,
        restoreData: itemData,
      },
    ]);
}
Enter fullscreen mode Exit fullscreen mode

Finally you remove the item from the client. For example if your object is being stored like the following

const [items, setItems] = useState([...])
Enter fullscreen mode Exit fullscreen mode

Then you will remove it like this

{
…
  setItem([...items.filter((item) => item.id !== itemData.id)]) 
}

Enter fullscreen mode Exit fullscreen mode

To undo

In the case a user wants to undo and prevent the deletion of an object you only need to update _removeMDF _but with the isUndo part of the object set to true.

const handleUndo = (itemId) => {
  setRemoveMFD({ id: itemId, isUndo: true }); // Notify the client that the object is being restored (undo)
}
Enter fullscreen mode Exit fullscreen mode

Listening to removeMFD and handling restoration

(listener)

Next we need to listen to removeMDF to both update the state and restore an item if the user requests to undo.

We initialize the listener, in functional React’s case we will utilize useEffect and inside it have a conditional _removeMDF _statement where all of the subsequent code will reside.

useEffect(() => {
  if (removeMFD) { // Verify that removeMFD is not null
  …
  }
, [removeMFD, setRemoveMFD, markedForDeletion, setMarkedForDeletion] // Pass in and listen to all of the states as is required by the hook. The key, though, is removeMFD.
Enter fullscreen mode Exit fullscreen mode

Then we handle the restoration if the user requests an undo. Note that the restoration depends entirely on how you handle the state.

{
    if (removeMFD) {
      const currentMFD = markedForDeletion.find(
        (mfd: MarkedForDeletion) => mfd.id === removeMFD.id
      )!; // Get the relevant markedForDeletion object from the list
    }
      // Restore cache if undo
      if (removeMFD.isUndo) {
        setItem([...items, currentMFD.itemData]) // repopulate items with the item being restored
      } else {
      }
}
Enter fullscreen mode Exit fullscreen mode

Then clean up the markedForDeletion and removeMFD states

{
…
      setMarkedForDeletion(
        markedForDeletion.filter(
          (mfd: MarkedForDeletion) => mfd.id !== removeMFD.id
        )
      );
      setRemoveMFD(null);
}
Enter fullscreen mode Exit fullscreen mode

Finally you have the full undo functionality at your disposal, all that is left to do is create a render component to make use of it.

Undos render component

(component)

The render component will serve as the interface for the user to undo an item if they have marked it for deletion.

First we set up the component with the variables required

const UndosComponent: React.FC = (
  markedForDeletion: MarkedForDeletion[],
  setRemoveMFD: (value: RemoveMFD) => void
) => {
…
}
Enter fullscreen mode Exit fullscreen mode

Then we create a handler for restoring an item

{
  const handleUndo = (markedForDeletionId: string) => {
    setRemoveMFD({ id: markedForDeletionId, isUndo: true });
  }
}
Enter fullscreen mode Exit fullscreen mode

Finally we create the render containing all of the items which are marked for deletion but not yet deleted

{
…
  return (
    <div>
      {markedForDeletion.map((item) => (
        <div key={item.id}>
          <button onClick={handleUndo}>UNDO</button>
          <p>{item.text}</p>
        </div>
      ))}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

And you're done 🥳! You now have a timed undo capable of handling multiple objects.

Conclusion

I hope this guide helped you to implement a client side undo feature. If you are interested in learning how to expand the undo functionality to handle an object represented in multiple states and effectively deleting and restoring all of them at once (Try it out with the labels feature in a Kuoly list), please feel free to let me know. Any feedback is more than welcome.timed undo for multiple objects. If you are interested in learning how to expand the undo functionality to handle an object represented in multiple states and effectively deleting and restoring all of them at once (Try it out with the labels feature in a Kuoly list), please feel free to let me know. Any feedback is more than welcome.

Latest comments (0)