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 principles behind undo-redo,
- that your components should not care about how they get their data, and
- that some concerns are better handled in isolation.
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*
.
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).
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.
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).
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.
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 apast
,present
, andfuture
state? - If the
count
state would be used in multiple places in our application, would we want all consumers to know about thepast
andfuture
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.
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.
Top comments (0)