Rewriting History: Adding Undo/Redo to Complex Web Apps
If you make a mistake in a sentence of your thesis - CTRL + Z - and your blunder is gone. Wrong brushstroke - click Undo - you’ll forget you ever did it. Although it may seem to be digital magic, the undo/redo feature we use in our daily lives is often taken for granted. In this article, we will explain how we managed to implement this digital time machine in Contentsquare’s complex web app. Let’s explore the issue and how we implemented a hybrid of Memento and Command patterns to abstract much of the intricacy.
To best comprehend this article, a foundational understanding of reactive state management is recommended. Specifically, we will focus on NgRx, an Angular state management system. However, the principles discussed can be applied to any other asynchronous state management system.
To provide context, I’d like to briefly introduce Contentsquare’s “Zoning” product before delving deeper. This is one of our most complex features, allowing users to conduct detailed analyses of their website’s usage. Users can create “zones” targeting specific areas of their website and obtain metrics for these zones. Given the product’s complexity and widespread use, we introduced an undo/redo feature. This allows users to easily modify previous actions and fine-tune their analyses, ensuring a seamless experience.
For the sake of simplicity, in this article I will model examples of our Contentsquare “Zoning” systems on the TodoMVC application. Since the specific product we engineered undo/redo (aka. history) is using Angular, you may check out how TodoMVC works on Angular 2. I suggest you play around with it to get a fuller taste of the app. To further enhance clarity, we will make assumptions and mention non-existent app behavior. It will help us further abstract some behaviors from the Contentsquare product.
The State-Tracking Challenge
If you fill in a reminder and press ENTER, it will be added to the list:
Behind the scenes, a new object will be added to the application store. Let us consider that each TODO is a JavaScript Object with multiple properties. Each object will be committed to the frontend store of the app. With that in mind, just like in Contentsquare’s product, we will need the configuration of a particular analysis to be saved beyond the current session. Backend operations make sure the configuration is properly retrieved if the previously saved analysis is requested.
If we sum up, here is what seems to happen from the user’s perspective:
From this perspective, it seems like we can easily undo/redo our changes by storing each state at a certain time ( State 1, State 2, State n, etc.). Then, the trick is simple, right? Clicking undo or redo would just take you to the correct state. Our state management could then function as a linked list:
It may be vaguely reminiscent of how the “back” and “forward” buttons work in a browser.
However, the mechanisms behind the manipulation of data structures in our product are not that simple. To give you an idea why, let’s invent a new scenario/feature in TodoMVC.
Imagine you have a TODO and a feature allows you to link that TODO to other tasks/goals you had on your app. For instance, we can picture that in your TODO app, you commit to a “Run today”, and that TODO is linked to other goals on the app such as “complete +1000 steps”. Ultimately, that would mean that the object linked to your TODO is linked to other objects and entities. You may then realize that if your application triggers an action to mark the TODO as “complete”, it will trigger other side-effects to manipulate all the configuration related to that TODO in frontend and backend. Ultimately, you will have a series of actions that look more like this (here we are referring to Angular NgRx actions):
Clearly it is a more complex flow than what we saw in Figure 2.
At first we could assume that at time=1 we have a State 1, at time=2 we have a State 2, and that we could undo/redo between user actions thanks to those assumptions. However, this assumption can lead to an unreliable solution.
As a timely side note, and to avoid potential confusion, I would like to insist on the difference between a “user action” (a click, an “enter” press, etc.) and an NgRx/app action, which represents a unit of work inside the state management system. I’ll use those terms distinctly. 1 user action can lead to multiple NgRx actions (cf. Figure 3).
Here is another schema to drive the point even further:
To resume with Figure 4 schema in mind: a single user action will trigger multiple NgRx actions into the observable stream. Consider another stream that contains NgRx actions triggered by a different user click (2nd arrow from top). When we select specific moments for committing a state as valid for undo/redo, concurrency issues arise.
These concurrent actions can result in unexpected state sequences. We want to make sure we only consider actions from a specific flow, so that we may undo a specific user action.
Crafting the Solution
Evidently, there is a recurring theme with issues related to concurrency - the complexity in determining which state to choose. If we look carefully at Figure 4, we can see that by isolating a stream of actions, we are able to execute work in the correct order to restore states. This will give the user the impression that the state is being restored as it was. In fact, we could simply redispatch the same actions. In essence, a redo would be a redispatching of all the same actions, and an undo would be the dispatching of all the opposite actions (more on that later).
Let’s look at the same schema, with the exception that each stream will be tagged with a specific “action id”.
Series of actions in a specific stream trigger each other in sequence, which is why it is possible to funnel an actionId
through them. From this figure, we can see that actions can be isolated/bundled and committed in a data structure. We now have the tools to eliminate concurrency issues and move between desired states through undo/redo.
Store Actions
As you would expect from a Memento pattern, our history mechanism will store all the information necessary to restore states. Let’s take our TODO example.
For the sake of this topic, let’s imagine a flow from adding a “Run 5 km” entry to your list. It will trigger your first action (aka unit of work) which will then trigger another series of actions. In this case, we can imagine that our 5km run is automatically linked to another set of objectives set on the app, like making “1000+ steps”. If we model the flow with what we have on our Contentsquare app, we would have something like:
An action “COMMIT_TODO” would have several side effects such as “NEW_STEPS_GOAL(1000)” and “COMMIT_TODO_BACKEND” (commits our entity changes to the backend). Once all that is achieved successfully, a new action would be scheduled: “ENTER_TODO_SUCCESS”.
To isolate this flow from other scheduled actions down other streams, we will group our actions as in Figure 5. In our product, we use UUIDs to identify our action groups. For simplicity, we will group our actions in Figure 6 under actionId = 123abc
.
Imagine we have a bunch of other user actions triggered in the same session. It means we would have several bundled actions, with different actionIds
. If we feed these bundles in an array, we will have something that looks like the following model:
Magic! We already have an array that contains all of our actions for redo! All that’s left is an array that contains a bundle of actions with equivalent opposites. To implement that, we assess all actions that need an opposite so that each change can be reversed. It allows us to go back to the same previous states.
In our imaginary TODO example, we have some of the following actions and their opposites:
Actions | Opposite actions |
---|---|
ADD_TODO_TO_UI | DELETE_TODO_TO_UI |
ADD_TODO_BACKEND | DELETE_TODO_BACKEND |
LINK_TODO_GOAL | UNLINK_TODO_GOAL |
We could implement this in TypeScript:
That allows us to have 2 arrays. One that contains all the packaged actions for redo, and the other for undo!
Full flow diagram
Remember from Figure 7, our packaged actions are in an array. So each actionId
corresponds to a specific index in our undo/redo. With our two arrays we would have something such as:
Our last action would correspond to index = 2.
With these arrays in mind, let’s imagine a user flow and explain how the data would be handled:
-
A user enters a last TODO (3rd user action throughout session): a last set of actions with
actionId
‘897thf’ would be pushed to both of our undo & redo arrays. -
User activates an undo → ‘897thf’ undo bundle is dispatched → restores previous state → idx = idx - 1 (now idx = 2)
-
User clicks undo again → ‘56yh’ undo bundle is dispatched → idx = idx - 1 (now idx = 1)
-
User activates a redo → ‘56yh’ redo bundle is dispatched → idx = idx + 1 (idx will change to 2; ready for the next undo/redo)
If the user triggers a new action in the middle of our array traversal, we splice the arrays at the relevant indexes.
Embracing Complexity, Delivering Simplicity
As we journeyed through the intricate mechanisms of state management at Contentsquare, it became evident that implementing an undo/redo feature isn’t just about backtracking or advancing through states. It’s about understanding user intent, capturing a sequence of interdependent actions, managing concurrency, and recreating or erasing specific sequences with precision. It’s like being a time traveler, but instead of traversing through time, you navigate through a labyrinth of states, actions, and interactions.
While our journey with the TodoMVC application provided a microscopic view, it’s essential to understand that the real-world applications of these principles span far beyond to-do lists. From e-commerce websites to advanced analytical tools such as Contentsquare, every platform’s nuances challenge engineers to think, innovate, and craft the perfect digital experience. The crux, however, remains consistent: anticipating needs, understanding behaviors, and delivering seamless solutions.