Command Pattern with Undo/Redo in TypeScript
Implement the Command pattern to encapsulate requests as objects, enabling undo/redo operations, request queuing, and operation logging
Overview
The Command pattern turns a request into a stand-alone object that carries everything needed to perform it. That decoupling lets you parameterize calls, queue or log operations and, the point of this page, implement undo/redo by giving each command an inverse operation. It’s the backbone of editors, drawing tools, form builders, and anywhere a user expects Ctrl+Z to just work.
Undo has two classic implementations. This page uses inverse commands: every Command knows how to undo itself (insert undoes by deleting the same range). The alternative, Memento, snapshots the receiver’s state and restores it. Inverse commands cost more to write but are compact; snapshots are trivial to implement but grow with document size. I reach for inverse commands once history depth or document size makes snapshots wasteful.
When to Use
- Interactive applications that need undo/redo: editors, canvases, designers.
- Operations you want to queue, log, schedule, or run remotely; a command object serializes better than a method call.
- Collaborative or transactional flows where each step may need to roll back independently.
When NOT to Use
- Simple CRUD with no history requirement; a plain method call is clearer.
- High-frequency mutations where storing a command per keystroke is too heavy; batch into composite commands or snapshot periodically.
- Operations you can’t write a reliable inverse for. A “send email” command can’t un-send; modeling it as undoable gives users a false expectation.
- Server-side state that changes independently of the UI. Undoing a local edit is easy; undoing what another user just changed is a different problem — that territory belongs to OT or CRDT algorithms, not a per-user history stack.
Problem
A text editor calls methods on a document object directly. Adding undo means exposing internal state or hacking a snapshot into every handler; adding macros means duplicating orchestration across the UI layer. The request (“insert 5 chars at position 3”) and the execution are welded together, so there’s nothing to store, replay, or reverse.
Solution
The fix is three roles: a Command interface with execute and undo, concrete commands that remember whatever they need to reverse, and an invoker that owns two stacks. The receiver stays dumb: it only knows how to mutate.
// The contract: every command can do and undo itself
interface Command {
execute(): void;
undo(): void;
getName(): string;
}
// Receiver — no history, no undo logic, just mutations
class TextDocument {
private content = '';
insert(text: string, position: number): void {
this.content =
this.content.slice(0, position) + text + this.content.slice(position);
}
delete(position: number, length: number): string {
const removed = this.content.slice(position, position + length);
this.content =
this.content.slice(0, position) +
this.content.slice(position + length);
return removed;
}
getContent(): string {
return this.content;
}
}
// Concrete commands store exactly what undo() needs
class InsertCommand implements Command {
constructor(
private document: TextDocument,
private text: string,
private position: number,
) {}
execute(): void {
this.document.insert(this.text, this.position);
}
undo(): void {
this.document.delete(this.position, this.text.length);
}
getName(): string {
return `Insert "${this.text}"`;
}
}
class DeleteCommand implements Command {
private deletedText = '';
constructor(
private document: TextDocument,
private position: number,
private length: number,
) {}
execute(): void {
this.deletedText = this.document.delete(this.position, this.length);
}
undo(): void {
this.document.insert(this.deletedText, this.position);
}
getName(): string {
return `Delete ${this.length} chars`;
}
}
// Invoker — two stacks, and the rule that makes undo honest
class CommandHistory {
private undoStack: Command[] = [];
private redoStack: Command[] = [];
execute(command: Command): void {
command.execute();
this.undoStack.push(command);
// A new action invalidates the redo stack — always
this.redoStack = [];
}
undo(): void {
const command = this.undoStack.pop();
if (!command) return;
command.undo();
this.redoStack.push(command);
}
redo(): void {
const command = this.redoStack.pop();
if (!command) return;
command.execute();
this.undoStack.push(command);
}
canUndo(): boolean {
return this.undoStack.length > 0;
}
canRedo(): boolean {
return this.redoStack.length > 0;
}
}
// Usage
const doc = new TextDocument();
const history = new CommandHistory();
history.execute(new InsertCommand(doc, 'Hello', 0));
history.execute(new InsertCommand(doc, ' World', 5));
console.log(doc.getContent()); // "Hello World"
history.undo();
console.log(doc.getContent()); // "Hello"
history.redo();
console.log(doc.getContent()); // "Hello World"
Note the invoker never inspects a command’s internals. It just moves them between stacks. That keeps CommandHistory generic — it works for any receiver, which is why real editors expose it as a reusable component.
Explanation
The part that makes undo work is asymmetry: execute produces effects, undo produces the inverse effect, and the command object is the only place that knows both. InsertCommand doesn’t snapshot the document; it just knows that inserting text at position is undone by deleting text.length characters at the same position. DeleteCommand does need memory (the deleted string), because deletion isn’t invertible without it. That’s the pattern’s real contract: each command stores the minimum state its inverse requires.
Two details decide whether the history behaves correctly:
- Clear redo on every new execute. If the user undoes twice and then types something new, the “future” those redo entries describe no longer exists. Forgetting
redoStack = []is the most common undo bug: users redo into a stale timeline and corrupt the document. - undo() pops, undo pushes to redo. The stacks move commands between each other; nothing is copied or recreated, so the command instance’s captured state (like
deletedText) survives the round trip.
On memory: inverse commands are small, but an unbounded history in a long-running editor still leaks. Cap undoStack (if (this.undoStack.length > 100) this.undoStack.shift()) or collapse old commands into a snapshot: a pragmatic hybrid where the last N commands are reversible and everything older is one Memento checkpoint.
Where this composes: commands are natural units for an operation log (serialize getName() + args to JSON for crash recovery), for queuing work on another worker, and for optimistic-UI flows where undo becomes the compensation for a failed server write.
How this compares to neighboring approaches
| Approach | What it stores | Memory cost | Undo fidelity | Best for |
|---|---|---|---|---|
| Command (this page) | Operation + inverse data | Low | Exact | Interactive undo/redo, operation logs |
| Memento | Full state snapshots | High | Exact | Small state, simple receivers |
| Event sourcing | Ordered append-only events | Medium, grows forever | Time-travel rebuilds | Audit logs, collaborative systems |
Event sourcing deserves a mention because it solves a related problem: store every change as an event and rebuild state by replaying. It gives stronger guarantees than an undo stack (the log is the source of truth, not a UI convenience), at the price of a much bigger model. For a text editor, commands are the right size.
One subtlety about determinism: undo() must restore state from what the command captured at construction, never by asking the receiver “what do you look like now.” If the user types between execute and undo, a position computed lazily is wrong; a position stored up front is still right. Treat command instances as immutable records once created.
Variants
MacroCommand — group operations into one undoable unit
class MacroCommand implements Command {
constructor(private commands: Command[]) {}
execute(): void {
for (const command of this.commands) command.execute();
}
// Undo in REVERSE order — the last applied change unwinds first
undo(): void {
for (const command of [...this.commands].reverse()) command.undo();
}
getName(): string {
return `Macro of ${this.commands.length} commands`;
}
}
This is how “paste 500 rows” or “format document” becomes a single Ctrl+Z instead of twenty.
AsyncCommand — undo for operations that return Promises
interface AsyncCommand {
execute(): Promise<void>;
undo(): Promise<void>;
}
class CommandQueue {
private undoStack: AsyncCommand[] = [];
// Sequential execution keeps ordering sane for undoable async work
async execute(command: AsyncCommand): Promise<void> {
await command.execute();
this.undoStack.push(command);
}
async undo(): Promise<void> {
const command = this.undoStack.pop();
if (command) await command.undo();
}
}
Async commands need a queue, not just a stack: two overlapping execute calls reorder history. Serialize them.
Bounded history — cap memory in long sessions
const MAX_HISTORY = 100;
execute(command: Command): void {
command.execute();
this.undoStack.push(command);
if (this.undoStack.length > MAX_HISTORY) {
this.undoStack.shift(); // oldest command falls off; can't be undone
}
this.redoStack = [];
}
Best Practices
- Store the minimum state needed for
undo()(the deleted text, the old value), not a document snapshot. - Clear the redo stack on every new
execute; stale redos corrupt documents. - Cap history length and collapse or snapshot beyond the cap.
- Keep commands deterministic: if
executecan’t reproduce the same effect on redo, undo/redo drifts. - Name commands for users (
"Insert text", notInsertCommand), because history panels showgetName(). - Log executed commands; the history object is a free audit trail.
Common Mistakes
- Snapshotting the whole document per command: memory grows with document size × history depth. Invert the operation instead; snapshot only as a periodic checkpoint.
- Not clearing the redo stack: after
undo → undo → new action, redo must be empty. Every mature editor follows this rule. - Undo that depends on current state:
undorestores by stored data, not by querying the document “where it is now”; positions may have shifted. - Modeling irreversible actions as undoable: network calls, emails, payments. Either exclude them or model undo as a compensating action the user can see.
- Commands that capture shared references: if a command holds a mutable object that later changes, its undo replays mutated state. Copy at construction.
- Letting every keystroke become a command: batch typing bursts into one insert command (per word or per pause), or history fills with noise.
Summary
- Each
Commandcarriesexecute()plus the minimal stateundo()needs to invert it. - The invoker owns the undo/redo stacks and clears redo on every new action.
- Inverse operations scale; full snapshots don’t. Combine with Memento for checkpoints.
- Macro and async variants cover the two shapes real editors need.
- Cap the history; an unbounded undo stack is a memory leak with a UI.
See Also
- refactoring.guru — Command — canonical description with the real-world analogies
- Design Patterns: GoF — the original catalog; Command is a behavioral pattern
- TypeScript Handbook — Classes — for
privateconstructor parameters and interfaces used here
Frequently Asked Questions
What's the difference between Command-based undo and Memento?
Command stores the operation plus the data needed to reverse it (small, surgical, more work per command). Memento stores the receiver's whole state (simple, heavier on memory). Editors often combine them: commands for recent history, snapshots as checkpoints.
Can I use commands for API request logging or queuing?
Yes. A command object is just data describing an operation, so it serializes naturally. Wrapping HTTP calls as commands gives you replay, retry queues, and an operation log for free.
Why does the redo stack get cleared on new commands?
Because after an undo, the redo entries describe a future that a new action invalidates. Editors treat a new action as creating a new branch; keeping old redos would let users "redo" into a document state that never coexisted with the new edit.
How do I keep the history from eating memory?
Cap the stack (drop the oldest command), batch noisy input (typing bursts) into single commands, and snapshot periodically so old commands can be discarded without losing undo entirely.
Is this pattern overkill for a small project?
Usually, yes. If there's no undo, queue, or replay requirement, an interface plus invoker adds indirection for nothing. Start with plain method calls; introduce commands when you actually need the history.
Can commands be shared between the client and a server?
They can. That's how collaborative editors and command-sourcing systems work. Serialize the command (name + args) over the wire; the receiver re-instantiates it. The caveat: inverses must be computed against server-side state, not the client's.
Related Resources
Memento Pattern for State Snapshot and Restoration
Capture and externalize an object's internal state without violating encapsulation, enabling undo, serialization, and state rollback in applications
PatternAbstract Factory for Cross-Platform UI Component Families
Create families of related objects without specifying concrete classes, enabling platform-specific implementations that share a common interface.
PatternDependency Injection Container in TypeScript
Build a lightweight DI container that resolves class dependencies automatically, enabling testable, loosely-coupled applications without frameworks like Angular or InversifyJS
PatternInterpreter Pattern for Domain-Specific Expression Languages
Build a language interpreter that evaluates expressions and rules by representing grammar as composable objects, useful for formulas, queries, and business rules
PatternVisitor Pattern: Extensible Operations in TypeScript
Separate algorithms from the objects they operate on, allowing new operations to be added without modifying existing element classes