Undo-redo and its effects on your architecture

Philipp Giese
October 12, 2020
10 min read
Undo-redo and its effects on your architecture

It's a running joke at Signavio that the product I'm working on does not support undo and redo. And even though we built the tool with redux we're still struggling to get that functionality in. I'm not going to bore you with the details of why it's hard for us but over the years(!) I got an idea of why it might be hard, in general, to get features like undo-redo into an existing app. The good news is that I believe there are steps you can take to make it easier.

TL;DR

I've built a library called react-undo-redo. Check it out on GitHub. 😄

However, this post is more about architecture than it is about undo-redo. The topic is a good example to showcase how certain choices for your architecture can help you built new features faster. Also, you'll see how small decisions can have large impacts. So, go ahead and learn about:

The basics of undo and redo

Let's imagine an application that processes state updates. If there is no notion of undoing or redoing then any update will create a new state which represents the current present. If you happen to use reducers then you might have encountered this as

f(state, action) => state*

Without undo-redo

Every time something happens inside our app we create a new present state. By doing this we also disregard any information about what came before. It becomes clear that to undo an action we'll need to keep track of our past and also our future (that's redo).

Undo-redo basics

The most basic implementation I know to built undo-redo is to introduce two stacks next to the present state. One stack keeps track of what happened before and the other stack tracks what would happen next. I like to use stacks because they best fit my mental model. This way we're coming from the past, move over to the present, and into the future. You could also use arrays and always append to the end but this would make popping items just that tiny bit harder.

With the above data structure in place, we need to establish some ground rules for how we want to work with it.

Progressing the present

Whenever an action happens that is neither "undo" nor "redo" we need to first move the current present into the past and then create a new present which is our updated application state.

Progressing the present

With every user action, we also need to clear the future stack. That's mostly to save ourselves from having some bad headaches. If we would not do that then with every "redo" we would need to figure out which of our possible futures we're going to choose. Everyone who has ever watched Back to the future knows that you don't want to do that.

By the way, I'm not talking about redux actions or reducers here. When I'm talking about actions in this article I mean "the user has done something and our application state is somehow updated". It's important to understand this to not get tricked into thinking undo-redo can solely be built with a redux based architecture.

Undo

You might have guessed already what is going to happen when we undo an action. When this happens we're going to take the most recent item that's in the past stack and make it our present. Also, we're pushing the current present into the future stack (that's important for redo).

Undo

Redo

When we "redo" an action we're making the immediate future our current present and the current present becomes the past again. Are you still with me? This is usually the point where I've made sure I've lost everyone in the room.

Redo

Now that I've confused you let's move on to the actual topic of this blog post: the effect this has on your application architecture.

How your state access might cause you trouble

For this section let's assume you have an architecture that uses a reducer and actions for state handling. Also, I'm going to use the classic "counter" example to keep it simple in here even though we all know the real world can be much harder. Without any notion of undo nor redo we have come up with the following code.

import React, { useReducer } from "react"

function counterReducer(state, action) {
  switch (action.type) {
    case "increment":
      return state + 1
    case "decrement":
      return state - 1
  }
}

function Counter() {
  const [count, dispatch] = useReducer(counterReducer, 0)

  return (
    <>
      Count: {count}
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
    </>
  )
}

What implications would it have if we would add undo-redo functionality to our application? Quite a lot I would say. We need to ask ourselves a couple of questions.

  • Do we want the counterReducer to "know" that there is a past, present, and future state?
  • If the count state would be used in multiple places in our application, would we want all consumers to know about the past and future bits as well?
  • What impact would it have if we later decide to use a different approach to get undo-redo working?

Personally, the last question got me thinking the most. You might have read my post about cohesion and the section about different kinds of coupling. If we would decide to make the application aware of the past and the present then this would mean a large value for afferent coupling. In other words, undo-redo then will be coupled to everything else in our application. Stuff like this keeps me awake in the evenings. It just sounds wrong.

import React, { useReducer } from "react"

function counterReducer(state, action) {
  switch (action.type) {
    case "increment":
      return {
        past: [state.present, ...state.past],
        present: state.present + 1,
        future: [],
      }
    case "decrement":
      return {
        past: [state.present, ...state.past],
        present: state.present - 1,
        future: [],
      }
  }
}

function Counter() {
  const [{ present: count }, dispatch] = useReducer(counterReducer, {
    past: [],
    present: 0,
    future: [],
  })

  return (
    <>
      Count: {count}
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
    </>
  )
}

I pondered some time about this topic and why it bothered me. After a while, it struck me. Of course, it had nothing to do with undo-redo. What I believe we often times get "wrong" (at least to a certain degree) is to move logic too close to the primitives (e.g. useReducer) that manage our state.

Alright, I'll explain.

Given its name, the Counter component should solely concern itself with counting. However, by directly using useReducer it also deals with state management on a very detailed level. The component "knows" that state is handled by a reducer. By coupling the view with how the state is managed we make it harder to change any one of these concerns. Luckily, every problem in software can be solved by adding a layer of indirection.

import React, { useReducer } from "react"

function counterReducer(state, action) {
  switch (action.type) {
    case "increment":
      return state + 1
    case "decrement":
      return state - 1
  }
}

function useCounter(initialCount) {
  const [count, dispatch] = useReducer(counterReducer, initialCount)

  return [count, dispatch]
}

function Counter() {
  const [count, dispatch] = useCounter(0)

  return (
    <>
      Count: {count}
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
    </>
  )
}

The new useCounter hook creates an abstraction that helps us to decouple the component from the details of the sate. With this change, we can make changes to how the state and reducers work without breaking the component. In other words, the Counter component does no longer care. However, the reducer still cares. A lot.

Extract concerns to make your life easier

In the last section, we decoupled our component code from the state. This way we could add undo-redo functionality without needing to make the component aware of that. However, the reducer would need quite some adjustments.

function counterReducer(state, action) {
  switch (action.type) {
    case "increment":
      return {
        past: [state.present, ...state.past],
        present: state.present + 1,
        future: [],
      }

    case "decrement":
      return {
        past: [state.present, ...state.past],
        present: state.present - 1,
        future: [],
      }

    case "undo": {
      const [present, ...past] = state.past

      return {
        past,
        present,
        future: [state.present, ...state.future],
      }
    }

    case "redo": {
      const [present, ...future] = state.future

      return {
        past: [state.present, ...state.past],
        present,
        future,
      }
    }
  }
}

If you'd ask me that is a lot of overhead in there. Have a look at the handlers for increment and decrement. Except for the present bit they are the same. By making our reducer aware of the undo-redo feature we're going to have to do a lot of extra typing in the future. Unless, of course, we have the final realization in this blog post. Your reducers probably also do not need to care about undo-redo.

Your reducers "live" in the present. So, why should they care about the past or the future? past and future are concepts that only undo-redo should be aware of. Otherwise, we're back to needing to change all reducers whenever we change something around undo-redo.

function createUndoRedo(presentReducer) {
  return function undoRedoReducer(state, action) {
    switch (action.type) {
      case "undo": {
        const [present, ...past] = state.past

        return {
          past,
          present,
          future: [state.present, ...state.future],
        }
      }
      case "redo": {
        const [present, ...future] = state.future

        return {
          past: [state.present, ...state.past],
          present,
          future,
        }
      }
      default: {
        return {
          past: [state.present, ...state.past],
          present: presentReducer(state.present, action),
          future: [],
        }
      }
    }
  }
}

Using the above createUndoRedo method we can enhance any other reducer and give it undo-redo powers. All without the need to make the reducer (or reducers) aware of this feature. How would this change our application code?

import React, { useReducer } from "react"

function counterReducer(state, action) {
  switch (action.type) {
    case "increment":
      return state + 1
    case "decrement":
      return state - 1
  }
}

function useCounter(initialCount) {
  const [{ present: count }, dispatch] = useReducer(
    createUndoRedo(counterReducer),
    {
      past: [],
      present: initialCount,
      future: [],
    }
  )

  return [count, dispatch]
}

function Counter() {
  const [count, dispatch] = useCounter(0)

  return (
    <>
      Count: {count}
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
    </>
  )
}

By extracting the concern of the undo-redo feature we can limit its effect on our implementation. The whole notion that we want something to be undoable is encapsulated inside the useCounter hook. This makes sense since, ultimately, this hook defines where the state lives and what shape it has. Since this is the place where we use createUndoRedo it's also reasonable that this hooks "knows" about the present part of the state. The important part still is that with this abstraction none of our reducers and none of our components need to know about this.

While I was writing this post I thought this would make a nice and small side-project. Enter stage: react-undo-redo, available today.


When do you use abstractions to better design your applications? Do you think I've made reasonable decisions or would you have done things differently? Let know on Twitter.

About the author

You can find information about me in the about section. I also like to give talks. If you'd like me to speak at your meetup or conference please don't hesitate to reach out via DM to @philgiese on Twitter.

Feedback

Did you like this article? Do you agree or disagree with something that I wrote? If so, then please drop me a line on Twitter

RSSPrivacy Policy
© 2024 Philipp Giese