Designing a lightweight undo history with TypeScript
Designing an extensible undo/redo history using TypeScript and the Command Pattern.
In desktop applications, the ability to undo or redo a user's latest action has already become ubiquitous many years ago. So it's not surprising that, with web applications replacing their desktop counterparts more and more, users are just expecting it from modern web applications as well. The good news is that implementing an undo history doesn't have to be hard. However, implementing it right from the beginning can save you a lot of headache later on.
When I started the development of JitBlox, I also decided that I wanted to offer undo-functionality right from the start. JitBlox is built with Angular, so a quick search for angular undo history already gave lots of useful links. However, most of the solutions I found were coupled to libraries that solved a lot more than just undo/redo. While that might be a good thing if one of these libraries fit within your architecture, I wanted to keep my implementation of such a key feature to be as much as possible framework-independent.
In this article, I will go through my design decisions while implementing my own, lightweight, undo/redo history in pure TypeScript. You can find a working implementation in a simple to-do manager app which can be found in this Git repository. Even though the demo is built with Angular, the core undo/redo functionality is framework independent (with a bit of RxJS on top of it to make things easier).
The application domain consists of just a single entity, which is named (as you might have already guessed) a TodoItem
.
export interface TodoItem {
id: number;
title: string;
description?: string;
priority?: number;
done?: boolean;
}
The app allows a user to add/remove to-do items and to update individual properties of a to-do item. It also
demonstrates two actions that are less generic: one for stepping a numeric property (priority
) with a specified
amount, and one for toggling a boolean value (done
).
Design goals
Let's start by setting some design goals to keep in mind during the process.
- Minimize dependencies: As already pointed out, I set myself a goal to build something independent from any state management library like Redux or it's Angular counterpart NgRx. The Redux principle can be very valuable to your application (e.g. forcing immutable state, caching), but this really depends on the type of application you are building.
- Lightweight: No snapshots of the application state. Some undo/redo solutions work by capturing the application state at each action (see the Memento pattern), meaning that objects need to be deep-cloned. While this may be ok for smaller applications, it might become inefficient when dealing with lots of data or a long undo-history.
- Benefit from TypeScript: design a strongly-typed API that is easy to understand and prevents callers from making mistakes.
- Extensible: Most undo/redo actions will apply to basic CRUD actions: adding new items to (or removing from) an array and updating properties of an object. But the solution shouldn't be restricted to that and allow for implementation of custom, domain-specific actions.
Choosing a design pattern
It doesn't take a lot of research to find that for implementing undo and redo, there are basically two design patterns to choose from:
- Memento pattern: A memento represents something you want to hold onto. For undo/redo, this means that an application must be able to revert to the state in which it existed before a user executed an operation.
- Command pattern: With the command pattern, you represent each operation as a unique object. As the user executes operations, corresponding command objects are stored in a history stack. As long as each command can support being "undone," undo and redo is a simple matter of tracking back and forth through this stack (or, typically a "past" and a "future" stack).
Both patterns have their own advantages and disadvantages. Here's a quick comparison:
Pros | Cons | |
---|---|---|
Memento | Easy to implement. | Uses more memory if the original objects are large. |
Command | Memory efficient, typically only keeps references to the original objects. | More work to implement, because it requires a separate command for each type of action. |
Decouples the classes that invoke the operation from the object that knows how to execute the operation. | ||
Allows for more complex data structures, collections or actions. |
I decided to go for the command pattern because it is lightweight and JitBlox has a relatively complex object model. The command pattern also has another advantage that I may want to make use of in the future: it allows for repeating the last action. In most cases, this does not make sense (you won't redo a "delete" action before you undo it), but sometimes it does: imagine a "duplicate item" command, which could be repeated infinitely.
Command design
Let's start by defining the core of our design (without reinventing the wheel): the Command interface. This interface needs to be implemented by any class that performs an undoable action of any kind.
export interface Command {
execute(): CommandResult;
undo(): CommandResult;
redo(): CommandResult;
}
Also, I defined a CommandResult
interface so that we know if the command got executed successfully and
we know if/how we can store
it in the history or communicate the result back to other parts of the application.
export interface CommandResult {
success: boolean;
canUndo?: boolean;
canRedo?: boolean;
}
The core of our design consists of the following types working together.
- UndoHistory: Stores executed commands in an undo- or redo-stack.
- CommandHandler: The
CommandHandler
is responsible for executing commands, managing theUndoHistory
and executing undo/redo commands.
Implementing the first command
Let's implement the first command: one that updates a single property on a target object.
export class UpdatePropertyCommand<TTarget> implements Command {
private previousValue?: any;
constructor(
private target: TTarget,
private propertyName: keyof(TTarget),
private newValue?: any
) {
}
public execute(): CommandResult {
this.previousValue = this.updateProperty(this.newValue);
return { success: true, canUndo: true };
}
public undo(): CommandResult {
this.updateProperty(this.previousValue);
this.previousValue = undefined;
return { success: true, canRedo: true };
}
public redo(): CommandResult {
return this.execute();
}
private updateProperty(value?: any): any {
const target = this.target;
const key = this.propertyName;
const currentValue = target[key];
target[key] = value;
return currentValue;
}
}
Note that this command uses keyof(TTarget)
to make sure that the property is really a property of the target object,
preventing callers from providing non-existent property names.
Executing a command
Now we have our first command (more commands will follow below), executing it looks as follows:
const commandHandler = new CommandHandler() ;
const command = new UpdatePropertyCommand<TodoItem>(myTodoItem, 'title', 'The new title'); // good input
const command = new UpdatePropertyCommand<TodoItem>(myTodoItem, 'foo', 'New foo value'); // bad input: compiler error!
commandHandler.execute(command);
While this works, there are some points for improvement:
- Callers must instantiate a concrete implementation of
UpdatePropertyCommand
. Wouldn't it be better to add some abstraction around the implementation details? - Callers might not even know that the
UpdatePropertyCommand
exists in the first place.
So what about refactoring the UpdatePropertyCommand
a little bit and separate the command information from the actual implementation?
/**
* Updates a single property of a target object.
*/
export interface UpdatePropertyCommandData<TTarget> {
/**
* The target object.
*/
target: TTarget;
/**
* The name of the property to be updated. The name must be a property of TTarget.
*/
propertyName: keyof(TTarget);
/**
* The new property value.
*/
newValue?: any;
}
And update our concrete implementation as follows:
export class UpdatePropertyCommand<TTarget> implements Command {
constructor(private commandData: UpdatePropertyCommandData<TTarget>) {
}
// Left out for the sake of brevity
}
We have now opened up a way to just pass the command information to the command handler, and leave the command instantiation inside.
const commandData: UpdatePropertyCommandData<TodoItem> = { target: myTodoItem, propertyName: 'title', value: 'The new title' };
commandHandler.execute(commandData);
But how do we let the command handler know that it needs to deal with an "update property" command and not another command that happens to use similar command data?
We could make an execute...
function for each command, but that would make the solution less maintainable. A better option would be to assign a key to each
command and let TypeScript ensure that each key is unique.
Implementing a command map
You may have seen the concept of event maps in TypeScript. An event map is, for example, used for mapping DOM event names to their corresponding event arguments. We can use the same mechanism for relating command keys to command data:
export interface CommonCommandMap<TTarget> {
'update-property': UpdatePropertyCommandData<TTarget>;
'another-command': SomeOtherCommand<TTarget>;
}
Let's update the signature of commandHandler.execute(...)
to use this command map:
public execute<K extends keyof CommonCommandMap<T>>(key: K, commandData: CommonCommandMap<T>[K]): CommandHandlerResult {
// based on key, determine what command implementation to instantiate
}
Now, a caller can only pass a value for key
that is part of the command map, with command data that
corresponds to the key.
commandHandler.execute('update-property', { target: myTodoItem, propertyName: 'title', value: 'The new title' }); // good input
commandHandler.execute('unkown-key', { target: myTodoItem, propertyName: 'title', value: 'The new title' }); // invalid key: compiler error!
commandHandler.execute('update-property', { target: myTodoItem, propertyName: 'title', foo: 'The new title' }); // invalid command data: compiler error!
Separate concerns
Our command execution interface already got easier to use: callers are just talking to the
CommandHandler
, which takes care of creating the right command instance,
executing it and adding it to the undo history. However, this approach isn't very flexible:
- Whenever a new command is implemented, we need to update the internals of our CommandHandler implementation to make it work.
- There is no way for callers to implement their own commands.
This brings me to the last update to our design, which is to move command instantiation to a factory method
and revert the commandHandler.execute
function so that it accepts any object that implements the
Command
interface.
export class CommonCommandFactory<> {
public create<K extends commonCommandKeys<T>>(key: K, commandData: CommonCommandMap<T>[K]): Command {
// note: isCommonCommand(...) is a small utility function that asserts that commandData has the correct type
if (isCommonCommand(commandData, key, 'update-properties')) {
return new UpdatePropertiesCommand<T>(commandData);
else if (isCommonCommand(commandData, key, 'another-command')) {
return new SomeOtherCommand<T>(commandData);
else ...
}
}
}
export class CommandHandler {
public execute(key: string, command: Command): CommandHandlerResult {
// execute, inspect result and add to history
}
}
Now, executing a command looks as follows:
const commandFactory = new CommonCommandFactory<TodoItem>();
const command = commandFactory.create('update-property', { target: myTodoItem, propertyName: 'title', value: 'The new title' });
commandHandler.execute(command);
This allows the CommandHandler
to accept any object that implements the Command
interface,
and we still have a strongly-typed factory
method for creating common commands. Also, the factory/command map duo can be easily extended to create other
(probably more domain-specific) commands:
export interface TodoCommandMap extends CommonCommandMap<TodoItem> {
// Implement todo-specific commands here
// 'my-todo-command': MyTodoCommandData;
}
export class TodoCommandFactory extends CommonCommandFactory<TodoItem> {
public create<K extends todoCommandkeys>(key: K, commandData: TodoCommandMap[K]): Command {
if (isTodoCommand(commandData, key, 'my-todo-command')) {
return new MyTodoCommand(commandData);
// Fallback to a common command
return super.create(key as commonCommandKeys<TodoItem>, commandData as any);
}
}
Implementing more generic commands
Until now, I've only used a generic update-property
command as an example. Even for basic
CRUD-applications we obviously need more than that. What about, for example, a command that updates multiple properties at the same time? Luckily they only need to be
implemented once, so I've already created most of them in the demo project.
Here are some of them with their data interfaces:
UpdatePropertiesCommand
A command to update multiple properties of a target object.
export interface UpdatePropertiesCommandData<TTarget> {
/**
* The target object.
*/
target: TTarget;
/**
* A partial instance of the target type with only the values to be updated.
*/
newValues: Partial<TTarget>;
}
As you can see, the UpdatePropertiesCommand makes use of TypeScript's built-in Partial<T>
type (a
type with all properties of Type set to optional). Example usage with a TodoItem
:
commandHandler.executeCommand('update-properties', { target: myTodoItem, newValues: {title: 'My title', priority: 5} }); // good input
commandHandler.executeCommand('update-properties', { target: myTodoItem, newValues: {title: 'My title', foo: 'bar'} }); // bad input: compiler error!
AddToChildCollectionCommand
Adds an element to a target object's child collection. The propertyName
can only be set to a property
that is actually an array of a specified type.
export interface AddToChildCollectionCommandData<TParent, TElement> {
/**
* The target object that has a collection property of type TElement.
*/
parent: TParent;
/**
* The property name of the child collection. The name must be
* an Array<TElement> property of TParent.
*/
propertyName: PropertiesHavingType<TParent, TElement[] | undefined>;
/**
* The element to add to the collection.
*/
element: TElement;
}
Likewise, the example project also contains commands for removing elements from a collection (either by reference or by index).
StepNumberCommand
Increments a numeric property with a specified amount. The example project also uses this command is also used to
increment (or decrement) a the priority of a TodoItem
by steps of 3 (you manager will love this -:).
This is a good example of a command that can be repeated infinitely.
export interface StepNumberCommandData<TTarget> {
/**
* The target object.
*/
target: TTarget;
/**
* The name of the numeric property to increment.
*/
propertyName: PropertiesHavingType<TTarget, number | undefined>;
/**
* The value by which the property is incremented. This can also
* be a negative number, decrementing the value.
*/
stepIncrement: number;
}
Responding to undo/redo actions
At some point, you will need to synchronize your data with a backend. How and when to do this depends on your state
management architecture, which is outside the scope of this article. The demo application demonstrates one way to
plug into the undo/redo history:
by making a wrapper around the CommandHandler
that makes all actions observable (using RxJS in this
case).
import { Command, CommandHandler, CommandHandlerResult } from '../history';
import { Observable, Subject } from 'rxjs';
export class CommandService {
private handler: CommandHandler;
private commandSuccessSubject = new Subject<CommandHandlerResult>();
constructor() {
this.handler = new CommandHandler();
}
/**
* An observable that emits a result each time a command is
* executed or undone successfully.
*/
public commandSuccess(): Observable<CommandHandlerResult> {
return this.commandSuccessSubject.asObservable();
}
private emitCommandSuccess(handlerResult: CommandHandlerResult | undefined): void {
if (!handlerResult || !handlerResult.result.success)
return;
// Notify commandSuccess subscribers
this.commandSuccessSubject.next(handlerResult);
}
public execute(key: string, command: Command): void {
const executeResult = this.handler.execute(key, command);
this.emitCommandSuccess(executeResult);
}
public undo(): void {
const undoResult = this.handler.undo();
this.emitCommandSuccess(undoResult);
}
public redo(): void {
const redoResult = this.handler.redo();
this.emitCommandSuccess(redoResult);
}
}
Then, a higher-level component or service can then subscribe to this as follows:
this.commandSuccessSubscription = commandService
.commandSuccess()
.subscribe((args) => {
const key = args.key as keyof TodoCommandMap; // contains the command's unique key
const action = args.action; // Execute, Undo, or Redo
// TODO: you may want to sync your data here
});
See it all in action
You can see it all in action by cloning the demo repository and runing npm install
followed by ng serve -o
.
I'm curious to know what you think (are there similar solutions out there that I've missed? are there any points for improvement?). Feel free to drop a comment.
Read next
Announcing JitBlox 1.0: online visual prototyping of modern web apps
The interactive, online environment for accelerating the design and prototyping of modern component-based web applications is now out of beta!
Save the date: JitBlox 1.0 is set to launch soon!
After many hours of testing and ticking off the last feature on our 1.0 roadmap, JitBlox is getting out of the Beta stage at friday, september 13th.
Engage project stakeholders with your app designs using always-available previews
Introducing always-available previews: keep the preview of your apps online, making your prototypes available to other project members or clients at their convenience.
Create better web apps faster using advanced template editing features
Introducing an even better prototyping experience using advanced template editing features such as collapsible regions, comments and converting your selection to a new component.
Invite your teammates to collaborate on your prototype
Starting today, you can invite your team mates to collaborate on your JitBlox project. Sharing projects has also become easier.
Introducing a real-time preview of your custom components
Introducing our component designer's new preview feature: a real-time preview of every custom component in your project.
Comments powered by Talkyard.