How to Implement Undo and Redo Functionality in the Excalidraw Editor

Excalidraw implements undo and redo through a stack-based history manager that stores inverse deltas of every mutable operation, allowing developers to record changes via history.record(delta) and trigger undo/redo through the createUndoAction and createRedoAction wrappers.

The Excalidraw editor relies on a sophisticated history system to power its undo and redo functionality. This article explores how the excalidraw/excalidraw repository implements this feature using inverse delta stacks, and how you can integrate it into custom implementations.

Understanding the History Core Architecture

The History Class and Stack Management

The foundation of Excalidraw’s undo/redo system lives in [packages/excalidraw/history.ts](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/history.ts). The History class maintains two private stacks: undoStack and redoStack, both typed as HistoryDelta[].

When the editor produces a StoreDelta (via element changes or app-state updates), the History instance stores its inverse rather than the change itself. This functional approach ensures that applying an inverse delta returns the scene to its previous state.

Recording Changes with Inverse Deltas

The record() method creates an inverse delta and manages stack state. It clears the redo stack only if element changes are non-empty, preventing simple UI clicks from discarding valuable redo history.

// record a new change – see lines 13‑33 in history.ts
public record(delta: StoreDelta) {
  if (delta.isEmpty() || delta instanceof HistoryDelta) return;

  const historyDelta = HistoryDelta.inverse(delta);
  this.undoStack.push(historyDelta);

  if (!historyDelta.elements.isEmpty()) {
    this.redoStack.length = 0; // reset redo on real element changes
  }
  this.onHistoryChangedEmitter.trigger(
    new HistoryChangedEvent(this.isUndoStackEmpty, this.isRedoStackEmpty),
  );
}

Performing Undo and Redo Operations

Both undo() and redo() delegate to a shared perform() method. This method repeatedly pops a delta, applies it to the current scene, and pushes the inverse onto the opposite stack. The loop continues until a delta produces a visible change that the user can perceive.

// public undo – lines 39‑46 in history.ts
public undo(elements: SceneElementsMap, appState: AppState) {
  return this.perform(
    elements,
    appState,
    () => History.pop(this.undoStack),
    (entry: HistoryDelta) => History.push(this.redoStack, entry),
  );
}

// public redo – lines 48‑55 in history.ts
public redo(elements: SceneElementsMap, appState: AppState) {
  return this.perform(
    elements,
    appState,
    () => History.pop(this.redoStack),
    (entry: HistoryDelta) => History.push(this.undoStack, entry),
  );
}

The perform() method handles the actual application of deltas, schedules micro-actions for synchronization, and emits HistoryChangedEvent so UI controls stay synchronized.

Integrating Undo and Redo into the UI

Creating Undo and Redo Actions

The UI layer connects to the history core through [packages/excalidraw/actions/actionHistory.tsx](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/actions/actionHistory.tsx). The createUndoAction and createRedoAction functions return Action objects that Excalidraw’s command infrastructure understands.

The executeHistoryAction helper safeguards against undo/redo while the user is actively editing (e.g., dragging or text editing). If the context is safe, it runs the supplied updater and returns the new elements and state.

// createUndoAction – lines 66‑78 in actionHistory.tsx
export const createUndoAction: ActionCreator = (history) => ({
  name: "undo",
  label: "buttons.undo",
  icon: UndoIcon,
  perform: (elements, appState, _, app) =>
    executeHistoryAction(app, appState, () =>
      history.undo(arrayToMap(elements) as SceneElementsMap, appState),
    ),
  keyTest: (event) =>
    event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
  PanelComponent: ({ updateData, data }) => {
    const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
      history.onHistoryChangedEmitter,
      new HistoryChangedEvent(history.isUndoStackEmpty, history.isRedoStackEmpty),
    );
    return (
      <ToolButton
        icon={UndoIcon}
        aria-label={t("buttons.undo")}
        onClick={updateData}
        disabled={isUndoStackEmpty}
        data-testid="button-undo"
        style={isMobile ? MOBILE_ACTION_BUTTON_BG : undefined}
      />
    );
  },
});

Keyboard Shortcuts and Toolbar Controls

The keyTest property defines keyboard shortcuts: Ctrl/Cmd+Z for undo and Ctrl/Cmd+Shift+Z (or Ctrl+Y on Windows) for redo. The PanelComponent renders toolbar buttons that automatically disable when the relevant stack is empty, listening to HistoryChangedEvent emissions.

Implementing Undo and Redo in Your Custom Code

Instantiating the History Manager

Create a History instance when initializing your editor store. This object will manage the undo and redo stacks for the entire session.

import { History } from "@excalidraw/excalidraw/history";
import { createStore } from "@excalidraw/element";

const store = createStore();               // your central store
const history = new History(store);        // <- keep reference for actions

Recording Custom Mutations

To make an operation undoable, compute a StoreDelta representing the change and call history.record(delta). This pushes the inverse delta onto the undo stack.

import { StoreDelta } from "@excalidraw/element";

function moveSelectedElements(dx: number, dy: number) {
  const delta = computeDeltaForMove(dx, dy); // returns StoreDelta
  history.record(delta);                     // push inverse onto undo stack
}

Registering Actions with the Application

Expose undo and redo functionality to users by registering the action creators with your application instance.

import { createUndoAction, createRedoAction } from "@excalidraw/excalidraw/actions/actionHistory";

app.registerAction(createUndoAction(history));
app.registerAction(createRedoAction(history));

Keyboard shortcuts are automatically wired via the keyTest callbacks defined in the action creators.

Practical Code Examples

Basic Usage Inside a Custom Tool

When building custom tools, you can integrate history recording directly into user interactions.

import { History } from "@excalidraw/excalidraw/history";
import { StoreDelta } from "@excalidraw/element";
import { createUndoAction, createRedoAction } from "@excalidraw/excalidraw/actions/actionHistory";

// assume `store` and `history` are created elsewhere
function MyCustomTool({ app }) {
  const onAddRect = () => {
    const delta = StoreDelta.fromElements([...]); // create delta for new rectangle
    history.record(delta);                       // make it undoable
    app.updateElements(delta.applyTo(...));      // apply immediately
  };

  return (
    <>
      <button onClick={onAddRect}>Add Rect</button>
      {/* Toolbar buttons are already provided by the actions */}
    </>
  );
}

// Register actions once (e.g., during app init)
app.registerAction(createUndoAction(history));
app.registerAction(createRedoAction(history));

Direct Undo/Redo Calls for Testing

For automated testing or programmatic control, you can invoke history methods directly or use the test helper.

import { Keyboard } from "./tests/helpers/ui";
import { History } from "@excalidraw/excalidraw/history";

const history = new History(store);

// Simulate user pressing Ctrl+Z
Keyboard.undo(); // internally calls `history.undo(...)`

Listening to Stack Changes

Build reactive UI components that respond to history state changes using the event emitter.

import { useEmitter } from "@excalidraw/excalidraw/hooks/useEmitter";
import { HistoryChangedEvent } from "@excalidraw/excalidraw/history";

function UndoRedoStatus({ history }: { history: History }) {
  const { isUndoStackEmpty, isRedoStackEmpty } = useEmitter<HistoryChangedEvent>(
    history.onHistoryChangedEmitter,
    new HistoryChangedEvent(history.isUndoStackEmpty, history.isRedoStackEmpty),
  );

  return (
    <div>
      Undo {isUndoStackEmpty ? "unavailable" : "ready"} – 
      Redo {isRedoStackEmpty ? "unavailable" : "ready"}
    </div>
  );
}

Key Files in the Excalidraw Repository

File Purpose Link
packages/excalidraw/history.ts Core stack implementation, History class, HistoryDelta, and event emitter. history.ts
packages/excalidraw/actions/actionHistory.tsx UI action wrappers (createUndoAction, createRedoAction) and keyboard shortcut handling. actionHistory.tsx
packages/excalidraw/tests/helpers/ui.ts Test-only Keyboard helper that triggers undo/redo, demonstrating expected shortcut signatures. ui.ts
packages/excalidraw/tests/history.test.tsx Comprehensive unit tests for undo/redo logic, confirming stack behavior and edge cases. history.test.tsx

Summary

  • Undo/redo in Excalidraw is a pure-function stack that stores inverse deltas rather than full state snapshots.
  • The History class in packages/excalidraw/history.ts manages the stacks, emits change events, and applies deltas safely through the perform() method.
  • UI integration is handled by action creators in actionHistory.tsx that bind toolbar buttons and keyboard shortcuts, automatically disabling controls when the relevant stack is empty.
  • To add undo/redo to any custom mutation, simply record the inverse delta with history.record(delta) and ensure the History instance is supplied to the action creators.
  • The architecture is compatible with Excalidraw’s collaborative synchronization layer, making it suitable for both single-player and multi-player implementations.

Frequently Asked Questions

How does Excalidraw handle undo and redo in collaborative sessions?

The History class operates on the local client state using inverse deltas. When you implement undo and redo functionality in the Excalidraw editor, the history manager applies changes to the local SceneElementsMap and AppState immediately. In collaborative sessions, these local changes are then synchronized through Excalidraw’s existing sync layer, ensuring that undo/redo operations affect only the local user's view before being broadcast to collaborators.

What is the difference between StoreDelta and HistoryDelta?

StoreDelta represents a generic change to the element store, while HistoryDelta is a specialized subclass that represents a reversible change. When you call history.record(delta), the system converts the StoreDelta into a HistoryDelta using HistoryDelta.inverse(delta), which calculates the opposite operation needed to restore the previous state. This inverse delta is what gets pushed onto the undoStack.

Can I disable undo/redo for specific operations?

Yes. The record() method in packages/excalidraw/history.ts automatically skips recording if the delta is empty or if it is already a HistoryDelta. Additionally, the executeHistoryAction helper in actionHistory.tsx safeguards against undo/redo while the user is actively editing (e.g., during drag operations or text input). You can implement similar guards by checking appState conditions before calling history.undo() or history.redo().

How do I test undo and redo functionality in my Excalidraw integration?

The repository provides a Keyboard test helper in packages/excalidraw/tests/helpers/ui.ts that simulates user input. You can use Keyboard.undo() and Keyboard.redo() to trigger the shortcuts programmatically in your test suite. For unit testing the history logic directly, instantiate the History class with a mock store, create StoreDelta objects representing changes, call history.record(), and assert that history.undo() restores the previous state. The file packages/excalidraw/tests/history.test.tsx contains comprehensive examples of this approach.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →