DEV Community

Cover image for Time Travel for Your State: Undo/Redo Done Right (Part 1)
math
math

Posted on Edited on

Time Travel for Your State: Undo/Redo Done Right (Part 1)

It’s probably my third day writing here. I’m not trying to keep a daily streak, but I get excited when I come across good code, so I end up wanting to share it right away.

Some background

At first, I thought undo/redo would be simple. That assumption lasted about 15 minutes.

In a personal project I’ve been working on, I spent the past week trying to find a clean, generic way to implement undo/redo without ending up with a bunch of hardcoded states. I wanted something that could manage itself instead of turning into a mess over time.

I’m glad to say I found two solid approaches. You can probably guess that from the title since this is Part 1 😄

In this part, I’ll walk through one approach that I found really elegant—the one recommended by Redux.

The idea

The core idea is simple. Instead of just storing your current state, you store a bit of history along with it.

{
  past: Array<T>,
  present: T,
  future: Array<T>
}
💬 ชาวเน็ตแห่แชร์: math krish/generic undo manager for react part dfh - ประเด็นร้อน 💬 ชาวเน็ตแห่แชร์: math krish/generic undo manager for react part dfh - ประเด็นร้อน
  • past → everything that came before
  • present → your current state
  • future → everything you’ve undone

Undo and redo then become operations where you just move state between these three.


Example: Todo list

Let’s walk through a simple todo list to make this concrete.

Start with an empty list:

past: []
present: []
future: []
💬 ชาวเน็ตแห่แชร์: math krish/generic undo manager for react part dfh - ประเด็นร้อน 💬 ชาวเน็ตแห่แชร์: math krish/generic undo manager for react part dfh - ประเด็นร้อน

Now you add a todo: "Buy milk"

past: [[]]
present: ["Buy milk"]
future: []
💬 ชาวเน็ตแห่แชร์: math krish/generic undo manager for react part dfh - ประเด็นร้อน 💬 ชาวเน็ตแห่แชร์: math krish/generic undo manager for react part dfh - ประเด็นร้อน

Add another todo: "Walk dog"

past: [[], ["Buy milk"]]
present: ["Buy milk", "Walk dog"]
future: []
💬 ชาวเน็ตแห่แชร์: math krish/generic undo manager for react part dfh - ประเด็นร้อน 💬 ชาวเน็ตแห่แชร์: math krish/generic undo manager for react part dfh - ประเด็นร้อน

Add one more: "Read book"

past: [[], ["Buy milk"], ["Buy milk", "Walk dog"]]
present: ["Buy milk", "Walk dog", "Read book"]
future: []
💬 ชาวเน็ตแห่แชร์: math krish/generic undo manager for react part dfh - ประเด็นร้อน 💬 ชาวเน็ตแห่แชร์: math krish/generic undo manager for react part dfh - ประเด็นร้อน

Now hit undo:

past: [[], ["Buy milk"]]
present: ["Buy milk", "Walk dog"]
future: [["Buy milk", "Walk dog", "Read book"]]
💬 ชาวเน็ตแห่แชร์: math krish/generic undo manager for react part dfh - ประเด็นร้อน 💬 ชาวเน็ตแห่แชร์: math krish/generic undo manager for react part dfh - ประเด็นร้อน

Hit undo again:

past: [[]]
present: ["Buy milk"]
future: [["Buy milk", "Walk dog"], ["Buy milk", "Walk dog", "Read book"]]
💬 ชาวเน็ตแห่แชร์: math krish/generic undo manager for react part dfh - ประเด็นร้อน 💬 ชาวเน็ตแห่แชร์: math krish/generic undo manager for react part dfh - ประเด็นร้อน

And if you redo:

past: [[], ["Buy milk"]]
present: ["Buy milk", "Walk dog"]
future: [["Buy milk", "Walk dog", "Read book"]]
💬 ชาวเน็ตแห่แชร์: math krish/generic undo manager for react part dfh - ประเด็นร้อน 💬 ชาวเน็ตแห่แชร์: math krish/generic undo manager for react part dfh - ประเด็นร้อน

This works because you’re not doing anything fancy—you’re just moving snapshots around.


Making it reusable

The really nice part is that this logic doesn’t have to live inside your reducer.

You can extract it into a reusable wrapper, or as Redux calls it, a reducer enhancer.

function undoable(reducer) {
  const initialState = {
    past: [],
    present: reducer(undefined, {}),
    future: []
  }

  return function (state = initialState, action) {
    const { past, present, future } = state

    switch (action.type) {
      case 'UNDO': {
        const previous = past[past.length - 1]
        const newPast = past.slice(0, past.length - 1)

        return {
          past: newPast,
          present: previous,
          future: [present, ...future]
        }
      }

      case 'REDO': {
        const next = future[0]
        const newFuture = future.slice(1)

        return {
          past: [...past, present],
          present: next,
          future: newFuture
        }
      }

      default: {
        const newPresent = reducer(present, action)

        if (present === newPresent) {
          return state
        }

        return {
          past: [...past, present],
          present: newPresent,
          future: []
        }
      }
    }
  }
}
💬 ชาวเน็ตแห่แชร์: math krish/generic undo manager for react part dfh - ประเด็นร้อน 💬 ชาวเน็ตแห่แชร์: math krish/generic undo manager for react part dfh - ประเด็นร้อน

Using it

You write your reducer like normal:

function todos(state = [], action) {
  switch (action.type) {
    case 'ADD_TODO':
      return [...state, action.text]
    default:
      return state
  }
}
💬 ชาวเน็ตแห่แชร์: math krish/generic undo manager for react part dfh - ประเด็นร้อน 💬 ชาวเน็ตแห่แชร์: math krish/generic undo manager for react part dfh - ประเด็นร้อน

Then wrap it:

const undoableTodos = undoable(todos)
💬 ชาวเน็ตแห่แชร์: math krish/generic undo manager for react part dfh - ประเด็นร้อน 💬 ชาวเน็ตแห่แชร์: math krish/generic undo manager for react part dfh - ประเด็นร้อน

And now it supports undo and redo out of the box:

store.dispatch({ type: 'ADD_TODO', text: 'Use Redux' })
store.dispatch({ type: 'ADD_TODO', text: 'Implement Undo' })
store.dispatch({ type: 'UNDO' })
💬 ชาวเน็ตแห่แชร์: math krish/generic undo manager for react part dfh - ประเด็นร้อน 💬 ชาวเน็ตแห่แชร์: math krish/generic undo manager for react part dfh - ประเด็นร้อน

One small gotcha

Since your state is now wrapped, you’ll need to access state.present instead of state.

You can also use:

  • past.length to know if undo is possible
  • future.length to know if redo is possible

Why this is nice

What I like about this approach is how composable it is. You can choose which reducers need undo and layer this on top without changing your existing logic.

Your original reducer stays untouched, and undo/redo becomes something you add, not something you bake in.

Also, if you don’t want to implement this yourself, redux-undo already does exactly this.

This article is heavily inspired by the official Redux documentation, so I’d definitely recommend checking that out as well.


In part 2, I talk about the approach I actually ended up using, and why it worked better for my setup, so give that a read if you found this interesting!

Thanks for reading
Until next time, 👋

Top comments (1)

💬 ชาวเน็ตแห่แชร์: math krish/generic undo manager for react part dfh - ประเด็นร้อน
 
sooraj_js profile image
sooraj js •

Hi Math,
Keep the good work up! I always end up learning something new and insightful everytime I read your article. I didnt know something as mindless as undo/redo needed so much thought put into it.
Looking forward to part 2!