Skip to main content

⌘Z · ⇧⌘Z

Undo and redo in a web editor: the command pattern, explained from zero

Implement undo and redo for a browser editor with the command pattern: commands, history stacks, grouping a drag into one step, and common traps.

14 min readEngineering

Part 3 of 15Build a browser video editor

⌘Z · ⇧⌘Zazelify
Engineering

Undo and redo in a web editor: the command pattern, explained from zero

Key takeaways

  • Route every change to the document through one function that records how to reverse it; a change that skips it cannot be undone.
  • Group continuous gestures such as a drag or a slider scrub into a single history entry, or one undo will only rewind a pixel.
  • Snapshots are simpler than inverse operations but cost memory; many editors combine structural sharing with small diffs.

Undo and redo in JavaScript are easier to build than most people expect, once you separate a change from the record of that change. This guide shows how to build undo redo javascript features from zero, using the command pattern: each edit becomes an object that knows how to apply itself and how to reverse itself, and a pair of stacks keeps those objects in order.

The guide is written for a reader who has written some JavaScript but has never built an editor. We define each term the first time we use it, including command, inverse, undo stack, coalescing and single-writer queue. The examples are small. The last sections explain how the Azelify editor handles the same problem, including the trade-offs we accepted.

What undo redo javascript actually has to do#

Undo reverses the last change to a document. Redo replays a change you just undid. That is the whole promise a person sees. The hard part is that a document changes in many small ways, and each change must be reversible without disturbing the changes made after it.

A document here means the data your editor edits, such as a list of clips and their settings. A state is the document at one moment in time. A mutation is any change that replaces or edits part of the state. The naive approach is to copy the whole document before every change and restore the copy on undo. That works for a tiny document, and it is a sensible first version to test against.

Copies fail as documents grow. If a project is a few megabytes and the history holds a thousand steps, you are storing a thousand copies, most of which are nearly identical. The rest of this guide shows the alternative: record how to reverse each change, and keep only the changes themselves.

Undo is also the feature people reach for when they are unsure, which makes it a trust feature. A person who presses undo expects exactly one thing to come back, in the order it happened. If undo removes two things, skips one, or brings back a stale version of a clip, the person stops believing the rest of the editor. That is why the design choices in this guide focus on predictability more than on speed.

What is the command pattern?#

The command pattern turns each request into an object. Wikipedia's summary (opens in a new tab) is short: it defines separate command objects that encapsulate a request, so the code that asks for the change does not need to know how the change is carried out.[1] For undo, the important detail is that the object can carry both directions.

Every command needs two methods. apply() makes the change, and undo() reverses it. Here is a command that adds a clip.

// A command is an object with two methods: apply() makes the change,
// and undo() reverses it. The history keeps the command, not just a result.
class AddClipCommand {
  constructor(document, clip) {
    this.document = document; // the state this command edits
    this.clip = clip;         // the data needed to add the clip
  }

  apply() {
    this.document.clips.push(this.clip);
  }

  undo() {
    // Find the clip by its id, not by its position in the list.
    const index = this.document.clips.findIndex((c) => c.id === this.clip.id);
    if (index !== -1) this.document.clips.splice(index, 1);
  }
}

Notice two decisions. The command stores the clip it added, so undo knows exactly what to remove. And undo finds that clip by its id rather than by where it sat in the list. Ids survive later edits, while list positions do not. Ids also stay meaningful if another part of the editor inserts a clip earlier in the list between the add and the undo.

Writing undo redo javascript: the undo stack#

The loop below shows what happens to one command over its life. Read it as a cycle: a command can be undone, redone and undone again, and the history moves it between two stacks each time.

Fig. 01
A command is applied, recorded, then moved between undo and redoIllustrative life of one command in a history. The command is created with its reverse. Applying it changes the document. Recording pushes it onto the undo stack. Undo pops it, runs its reverse and moves it to the redo stack. Redo re-applies it and returns it to the undo stack, so the cycle can repeat.
CommandEdit plus its reverse
ApplyChange the document
RecordPush onto undo stack
UndoPop, run the inverse
RedoRe-apply the command
  1. 01 → CommandEdit plus its reverse
  2. 02 → ApplyChange the document
  3. 03 → RecordPush onto undo stack
  4. 04 → UndoPop, run the inverse
  5. 05 → RedoRe-apply the command
A command is applied, recorded, then moved between undo and redo
Illustrative, not data[wiki-command]

An undo stack is a list used last in, first out. The newest command sits on top, and undo pops it. A redo stack holds commands that were undone, so redo can pop them back. Here is a small history class that does both.

class History {
  constructor(limit = 1000) {
    this.undoStack = [];
    this.redoStack = [];
    this.limit = limit; // keep memory bounded
  }

  // Run a command, remember it, and forget any redo history.
  execute(command) {
    command.apply();
    this.undoStack.push(command);
    if (this.undoStack.length > this.limit) this.undoStack.shift();
    this.redoStack = []; // a new edit makes the old redo path invalid
  }

  undo() {
    const command = this.undoStack.pop();
    if (!command) return false; // nothing to undo
    command.undo();
    this.redoStack.push(command);
    return true;
  }

  redo() {
    const command = this.redoStack.pop();
    if (!command) return false; // nothing to redo
    command.apply();
    this.undoStack.push(command);
    return true;
  }
}

Read the execute method closely. It applies the command, pushes it onto the undo stack, and clears the redo stack. Clearing the redo stack is a rule with a reason. Once someone makes a new edit after undoing, the old redo path no longer describes the document, because the document has moved on.

Step-by-step: wiring undo into an editor#

The four steps below turn a set of edit functions into an editor with reliable undo. Each one leaves you with something that runs.

Step 1: make every change a command#

List the edits your editor makes, such as adding, deleting, moving and changing a setting. For each one, write a command class with apply and undo. If you cannot write the undo, the edit is hard to reverse, and that is worth knowing before you ship it. Either redesign the edit or store enough data to reverse it.

Storing the change itself is the same idea that event sourcing (opens in a new tab) uses at larger scale: keep the record of what happened, and derive the current state from it.[2]

Step 2: route every write through the history#

Panels, keyboard shortcuts and automated jobs must all call history.execute instead of changing the document directly. This is the rule the rest of the guide depends on. A change that skips the history cannot be undone, and the person will eventually find it.

const doc = { clips: [] };
const history = new History();

history.execute(new AddClipCommand(doc, { id: "c1", startTime: 0 }));
history.undo(); // doc.clips is empty again
history.redo(); // doc.clips holds c1 again

Step 3: test a round trip for every command#

For each command, run apply, then undo, then compare the result with the original state. Then run redo and compare with the state after apply. A failing test tells you which command lost information. The testing section below shows a complete version.

Step 4: connect the keyboard#

Bind the standard shortcuts to undo and redo, handle the Command key on Macs, and leave text fields alone. The keyboard section below has the details.

Inverse operations, snapshots or patches?#

There are three common ways to store what it takes to reverse a change. Each one trades memory against simplicity, and the table sets them side by side.

ApproachWhat it stores per stepCost of undoMemory over a long sessionMain risk
SnapshotA full copy of the stateCopy the stored state backGrows with state size times history lengthEasy to write, expensive to keep
Inverse commandThe data needed to reverse one editRun the reverse functionGrows with the size of each editEvery edit needs a correct inverse
PatchThe paths that changed, in both directionsApply the inverse patchSmall, grows with the changeDepends on a library or careful generation

The memento pattern (opens in a new tab) is the classic name for snapshots. It captures an object's internal state so the object can be restored later, without exposing that state to the rest of the program.[3] Snapshots are a fine place to start, and they are easy to test against.

The inverse-command approach is the one used in the examples above. Each command knows its own reverse, so memory grows with the size of each change rather than the size of the document. Patches sit between the two. A patch records the paths that changed, and a library can generate the reverse for you. The immer library (opens in a new tab) documents produceWithPatches, which returns the next state together with patches and inverse patches.[4][5] The code below uses it to undo one edit.

import { produceWithPatches, enablePatches, applyPatches } from "immer";
enablePatches(); // call once at startup so patch generation is available

const base = { clips: [{ id: "c1", startTime: 0 }] };

// The recipe is the only code you write. Immer returns the next state
// plus the patches to go forward and the inverse patches to go back.
const [next, patches, inversePatches] = produceWithPatches(base, (draft) => {
  draft.clips[0].startTime = 4;
});

// Undo: apply the inverse patches to the current state.
const undone = applyPatches(next, inversePatches);
// undone.clips[0].startTime is 0 again, and base was never mutated.

Reading the result: next is the new document, inversePatches describes how to return to the original, and applyPatches performs the reversal. You still need a history to hold the entries, which is the same stack you built above. The patches simply become the command's data.

Grouping a drag or a typed word into one step#

A drag can update the document a hundred times in one second. If each update became an undo step, one undo would move a clip back by a single pixel, and the person would press undo many times before anything visible changed. The fix is grouping: several changes that belong to one gesture become one history entry.

There are two common approaches. The first opens a group when a gesture starts and closes it when the gesture ends. The second is coalescing: when the same kind of change arrives within a short time window, merge it into the previous entry. Text fields often coalesce, so a word typed in one burst becomes one undo step. Drags usually use an explicit group, because the end of the gesture is known. The code below shows the explicit group.

// Changes made between beginGroup and endGroup become one undo entry.
class GroupedHistory extends History {
  constructor(limit) {
    super(limit);
    this.group = null;
  }

  beginGroup() {
    this.group = [];
  }

  // Apply the change now, but hold it in the group instead of the stack.
  record(command) {
    command.apply();
    this.group.push(command);
  }

  endGroup() {
    const commands = this.group;
    this.group = null;
    if (commands.length === 0) return;
    this.undoStack.push(new CompositeCommand(commands));
    this.redoStack = [];
  }
}

// One entry that applies its parts in order and undoes them in reverse.
class CompositeCommand {
  constructor(commands) {
    this.commands = commands;
  }

  apply() {
    for (const command of this.commands) command.apply();
  }

  undo() {
    for (const command of [...this.commands].reverse()) command.undo();
  }
}

Notice that record applies each change but pushes nothing. Only endGroup adds a single composite entry. The reverse order in undo matters. The last change must be reversed first, or the reversal of an earlier change may refer to a state that no longer exists.

The funnel below makes the goal visible. The code runs once per pointer move, but the person made one decision, so the history should hold one entry.

Fig. 02
One drag produces 180 document updates but only one undo entryIllustrative numbers for a single drag gesture. The pointer moved 180 times, the document was updated 180 times, and the grouped history holds one undo entry. The ratio from pointer moves to undo entries is one entry per 180 moves, which shows why grouping matters.Pointer moves180Document updates180Undo entries1Entries per move: 0.6% · undo entries / pointer moves
  1. Pointer moves180
  2. Document updates180
  3. Undo entries1
  • Entries per move: 0.6% · undo entries / pointer moves
One drag produces 180 document updates but only one undo entry
Illustrative, not data

Not every change deserves a group, and not every group should merge with the one before it. The decision tree below helps you choose.

Fig. 03
Gestures merge into one step, and separate actions keep their ownDecision tree for undo grouping. If the change is part of one continuous gesture, the tree asks whether its steps can be merged. If they can, the gesture gets one history entry. If they cannot, the steps stay separate and the outcome is marked with caveats. A change that is not part of a gesture gets its own undo step.yesyesnono
Is it one continuous gesture?
Can its steps be merged?
One entry for the gesture
Keep steps separate
Its own undo step
  1. Is it one continuous gesture?
    1. yes → Can its steps be merged?
      1. yes → One entry for the gesture
      2. no → Keep steps separate
    2. no → Its own undo step
Gestures merge into one step, and separate actions keep their own
Illustrative, not data

For pointer gestures, make sure the group closes even when the pointer leaves the element. The pointer capture API (opens in a new tab) routes later pointer events to the element that captured them, which is how a drag keeps working outside its own bounds.[6]

Redo invalidation and branching#

Redo has one rule that surprises people. A new edit clears the redo stack. Suppose someone adds a clip, undoes it, and then adds a different clip. The redo path for the first clip is gone, because the document now describes a different history.

Consider a short session. A person trims a clip, moves a second clip, and then undoes twice, which returns the second clip and then the trim. If they now drop a new title on the track, both the move and the trim are no longer redoable, because the title is a new edit that clears the redo stack. The straight stack makes that outcome predictable: one edit after an undo always means the old future is gone.

Some editors keep every branch as a tree, so a person can return to any past timeline. That is more powerful, but it is harder to show a person and harder to store. Most editors choose the straight stack, because it matches how people think about undo: one step back, then one step forward, with no hidden alternatives.

A related case is speculative work. Sometimes the editor wants to try a command and discard it if validation fails. A common technique is to fork the history: create a private child that shares the existing entries by reference, stage the command there, and touch the real history only if the command is accepted. Sharing by reference avoids copying a long history on every attempt.

A single-writer queue keeps undo honest#

Asynchronous code creates a subtle problem. If two commands start before either finishes, their reads and writes can interleave. The undo entry for one command may then describe a state the other has already changed. A single-writer queue prevents that by making every change wait for the previous change to finish.

Here is a concrete failure. Imagine a trim command that reads a clip's end time, waits for a slow thumbnail job, and then writes the new end time. While it waits, a second command moves the same clip. When the trim writes its result, it overwrites the move with stale data. The undo entry for the move now reverses a change that no longer exists, so one undo leaves the clip in a place nobody chose. Nothing crashed, which is exactly why this bug is hard to find without a queue.

// Each task starts only after the previous one has settled.
class WriteQueue {
  constructor() {
    this.tail = Promise.resolve();
  }

  run(task) {
    const result = this.tail.then(() => task());
    // A failed task must not block the queue, so the tail swallows the error.
    this.tail = result.catch(() => {});
    return result;
  }
}

const queue = new WriteQueue();
queue.run(() => history.execute(new AddClipCommand(doc, { id: "c2", startTime: 4 })));

Two details matter. Each task is chained onto the previous one, so the queue runs in order. And a failed task cannot block the queue, which is why the stored tail recovers from errors. Undo and redo should pass through the same queue, so an undo can never race an edit.

In our editor, every write goes through one queue, including undo and redo. A command that bundles several actions runs as a transaction. The transaction stages its changes on a copy and publishes them only when the whole batch validates. If the seventh action fails, the document, the history and the selection stay exactly as they were before the first action.

Keyboard shortcuts for undo and redo#

Most people expect Command-Z on a Mac and Control-Z elsewhere to undo, with Shift added to redo. MDN (opens in a new tab) documents ctrlKey as the flag that reports whether the Control key was held during a keyboard event.[7] The metaKey flag reports the Meta key, and MDN notes that on Macintosh keyboards it is the Command key.[8] Checking both keeps one shortcut working on both platforms.

window.addEventListener("keydown", (event) => {
  // metaKey is the Command key on a Mac; ctrlKey is Control elsewhere.
  const modifier = event.metaKey || event.ctrlKey;
  if (!modifier || event.key.toLowerCase() !== "z") return;

  // Let text fields keep their own undo while they have focus.
  const target = event.target;
  if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) return;

  event.preventDefault();
  if (event.shiftKey) history.redo();
  else history.undo();
});

Testing undo: the property you actually want#

The property you want is simple to state. After any sequence of commands, undoing all of them returns the document to where it started, and redoing them all returns it to where it ended. A unit test checks one example. A property-based test generates many random sequences and checks the rule for each one. The renderer testing guide covers property-based tests in more depth.

import { expect, test } from "bun:test";

test("undo returns the document to its exact earlier state", () => {
  const doc = { clips: [] };
  const history = new History();
  const before = structuredClone(doc); // a deep copy to compare against

  history.execute(new AddClipCommand(doc, { id: "c1", startTime: 0 }));
  history.undo();

  expect(doc).toEqual(before);
});

test("redo restores exactly the state that undo removed", () => {
  const doc = { clips: [] };
  const history = new History();
  history.execute(new AddClipCommand(doc, { id: "c1", startTime: 0 }));
  const after = structuredClone(doc);

  history.undo();
  history.redo();

  expect(doc).toEqual(after);
});

Start with one command and one undo. Then extend the test to a random mix of adds and deletes, and check the rule after every step. When a generated test fails, the shortest failing sequence tells you which command is wrong.

How the Azelify editor handles undo#

The Azelify editor follows the same shape as this guide, with more rules around it. Every edit is an action: a typed, serializable request that is checked before it touches the document. Each reversible action receives its inverse at the moment it is applied, not when someone presses undo. That choice means undo never has to reconstruct history it no longer has.

If the editor cannot produce an inverse for an action, the action is refused before anything changes. The alternative would be to apply the change and discover later that it cannot be reversed, which leaves an unrecoverable gap that blocks every older step behind it. The cost is that a new kind of edit needs its inverse before it can ship.

Changes pass through a single-writer queue. A command can bundle several actions into one transaction, and simple repeated edits of the same kind, such as keystrokes in a text field, are coalesced when they arrive within a short window of about a tenth of a second. The history holds up to a thousand steps. When it is full, the oldest step is dropped quietly, so a very long session can run out of undo without warning. Undo history lives in memory for the session and is not saved with the project.

Forks let the editor stage one speculative command in a private history, as described above. The same executor can also run on a server for automated jobs, with a check that refuses any change computed against a stale copy of the document.

Common mistakes that break undo#

Storing references instead of values. If a command keeps a pointer to an object that later changes, its undo restores the wrong thing. Store the id and the old values. When you must keep a copy, use structuredClone, which MDN documents (opens in a new tab) as the way to copy complex objects, and web.dev explains (opens in a new tab) in more depth.[9][10]

Redo that bypasses apply. Redo should run the same code as the first execution. If it does not, the redone result can differ from the original one, and the mismatch is hard to see.

Undoing by position. Use ids, as the clip example shows. Positions shift whenever another item is added or removed.

Unbounded history. A long session can exhaust memory. Set a limit, and decide in advance what happens when the limit is reached.

Frequently asked questions#

How much code does basic undo redo in JavaScript need?#

A working stack with execute, undo and redo methods fits in about forty lines, as the code above shows. Most of the effort goes into the commands themselves, because each edit needs a correct apply and a correct undo.

Is copying the whole state for each change acceptable?#

For a small document, yes. It is a fine first version and a useful reference for testing. It stops being acceptable when the document is large or the history is long, because memory grows with both. Move to inverse commands or patches when your measurements show the cost.

How many undo steps should I keep?#

A limit around a thousand steps is a common and defensible default. Make it a setting in your code rather than a number scattered through the app, so you can change it after you measure.

How do I keep history in a React editor?#

Keep the history outside the components. A React component re-renders when its state changes, so the history should live in a plain object or store that components read from. Undo then becomes a function call, rather than a side effect of rendering.

Why does one undo revert only part of my drag?#

Usually because each pointer move created its own history entry. Group the gesture between pointer down and pointer up, and make sure the group closes when the pointer is released, even if it has left the element.

Should undo history be saved with the project?#

Usually not. Undo history belongs to one session and can hold large intermediate states. Save the project itself, and let undo start fresh when the project loads. Named checkpoints are a better tool for a durable point to return to, as the project file design guide explains.

Where to go next#

The project file design guide shows how the document that these commands edit should be saved, versioned and reloaded. The timeline data model article explains what the clip fields mean when trims, ripples and overlaps change them.

Sources

  1. Wikipedia, “Command pattern,” en.wikipedia.org (opens in a new tab) (accessed 2026-10-13). ↩

  2. Reactive Design Patterns, “Event sourcing,” reactivedesignpatterns.com (opens in a new tab) (accessed 2026-10-13). ↩

  3. Source Making, “Memento Design Pattern,” sourcemaking.com (opens in a new tab) (accessed 2026-10-13). ↩

  4. Immer, “Patches,” immerjs.github.io (opens in a new tab) (accessed 2026-10-13). ↩

  5. Immer, “API overview,” immerjs.github.io (opens in a new tab) (accessed 2026-10-13). ↩

  6. MDN Web Docs, “Element: setPointerCapture() method,” developer.mozilla.org (opens in a new tab) (accessed 2026-10-13). ↩

  7. MDN Web Docs, “KeyboardEvent: ctrlKey property,” developer.mozilla.org (opens in a new tab) (accessed 2026-10-13). ↩

  8. MDN Web Docs, “KeyboardEvent: metaKey property,” developer.mozilla.org (opens in a new tab) (accessed 2026-10-13). ↩

  9. MDN Web Docs, “Window: structuredClone() method,” developer.mozilla.org (opens in a new tab) (accessed 2026-10-13). ↩

  10. web.dev, “Deep-copying in JavaScript using structuredClone,” web.dev (opens in a new tab) (accessed 2026-10-13). ↩

Build a browser video editor

In this series

  1. 01How to build a video editor in the browser: the complete architecture
  2. 02Designing the project file for a web video editor: schema, versions and autosave
  3. 03Undo and redo in a web editor: the command pattern, explained from zero
  4. 04How a video editor timeline works: tracks, clips, trims and overlaps
  5. 05Importing video files in the browser: probing, codecs, proxies and storage
  6. 06WebCodecs tutorial: decoding video frames in the browser without a video tag
  7. 07Frame-accurate playback in JavaScript: clocks, requestAnimationFrame and A/V sync
  8. 08Compositing video layers with Canvas and WebGL: transforms, blends and effects
  9. 09Text and animated captions in a browser video editor: let CSS do the drawing
  10. 10Drag, resize and rotate handles for a canvas editor: building the transform gizmo
  11. 11Web Audio API for video editors: mixing tracks, drawing waveforms and loudness
  12. 12Exporting MP4 from the browser: WebCodecs encoding, muxing and server fallback
  13. 13Keyframe animation in a web editor: interpolation, easing curves and motion tracking
  14. 14How to test a video renderer: golden images, oracles and property-based tests
  15. 15FFmpeg.wasm vs WebCodecs vs MediaRecorder: choosing a browser video stack

Try an editor that remembers every step

Make a dozen edits, then walk them back one at a time.

Open the studio

Keep reading

More in Browser video editor

All guides