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).

To-do manager demo app

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.

Undo/redo class diagram
  • UndoHistory: Stores executed commands in an undo- or redo-stack.
  • CommandHandler: The CommandHandler is responsible for executing commands, managing the UndoHistory 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

Comments powered by Talkyard.