Loading content...
Every sophisticated application provides undo functionality. Word processors, graphic editors, IDEs, spreadsheets, 3D modeling tools—users expect to reverse mistakes, experiment freely, and navigate through their action history.
But implementing undo is notoriously challenging. Each operation type requires its own reversal logic. State must be captured before modifications. Multiple undo levels demand stack management. Redo adds another layer of complexity. And edge cases—partial failures, interleaved operations, resource cleanup—can break even well-designed systems.
The Command Pattern provides the architectural foundation for robust undo/redo, but the devil is in the details. This page explores those details thoroughly.
By the end of this page, you will understand how to implement multi-level undo/redo systems: managing undo and redo stacks, designing command state capture, handling complex undo scenarios, and addressing real-world challenges like memory limits, compound operations, and state consistency.
The fundamental data structure for undo/redo is the dual-stack model: an undo stack and a redo stack. Commands move between these stacks as users execute, undo, and redo operations.
undo(), push onto redo stack.execute(), push back onto undo stack.This creates a linear history that users can navigate forward and backward.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
interface UndoableCommand { execute(): void; undo(): void; getDescription(): string;} class UndoRedoManager { private undoStack: UndoableCommand[] = []; private redoStack: UndoableCommand[] = []; private maxHistorySize: number; constructor(maxHistorySize: number = 100) { this.maxHistorySize = maxHistorySize; } /** * Execute a command and add it to the undo history. * This clears the redo stack—any "future" is discarded. */ executeCommand(command: UndoableCommand): void { command.execute(); this.undoStack.push(command); this.redoStack = []; // Clear redo on new action // Enforce history size limit if (this.undoStack.length > this.maxHistorySize) { this.undoStack.shift(); // Remove oldest } } /** * Undo the most recent command. * Moves the command from undo stack to redo stack. */ undo(): boolean { const command = this.undoStack.pop(); if (!command) { return false; // Nothing to undo } command.undo(); this.redoStack.push(command); return true; } /** * Redo the most recently undone command. * Moves the command from redo stack back to undo stack. */ redo(): boolean { const command = this.redoStack.pop(); if (!command) { return false; // Nothing to redo } command.execute(); this.undoStack.push(command); return true; } // Query methods for UI canUndo(): boolean { return this.undoStack.length > 0; } canRedo(): boolean { return this.redoStack.length > 0; } getUndoDescription(): string | null { const top = this.undoStack[this.undoStack.length - 1]; return top ? `Undo ${top.getDescription()}` : null; } getRedoDescription(): string | null { const top = this.redoStack[this.redoStack.length - 1]; return top ? `Redo ${top.getDescription()}` : null; } getUndoHistory(): string[] { return this.undoStack.map(c => c.getDescription()).reverse(); } getRedoHistory(): string[] { return this.redoStack.map(c => c.getDescription()).reverse(); } clearHistory(): void { this.undoStack = []; this.redoStack = []; }}When a user performs a new action after undoing, the redo stack is cleared. This maintains a linear history. Consider the alternative:
If we kept B and C as potential redos, the history becomes a tree—complex to navigate and confusing for users. Most applications discard future states when the user "branches" with a new action.
Unbounded undo history consumes memory indefinitely. Most applications limit history size (e.g., 100 undos in Photoshop, configurable in IDEs). When the limit is reached, oldest commands are dropped from the bottom of the undo stack. Commands may perform cleanup in a dispose() method when evicted.
The key to reliable undo is capturing sufficient state to restore the previous condition. There are several strategies for state capture, each with tradeoffs.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
// Only capture what changes—most memory-efficientclass MoveShapeCommand implements UndoableCommand { private shape: Shape; private dx: number; private dy: number; private previousX: number = 0; private previousY: number = 0; constructor(shape: Shape, dx: number, dy: number) { this.shape = shape; this.dx = dx; this.dy = dy; } execute(): void { // Capture ONLY the affected state this.previousX = this.shape.x; this.previousY = this.shape.y; // Apply transformation this.shape.x += this.dx; this.shape.y += this.dy; } undo(): void { // Restore to captured state this.shape.x = this.previousX; this.shape.y = this.previousY; } getDescription(): string { return `Move shape (${this.dx}, ${this.dy})`; }} // For text editing—capture only the deleted textclass DeleteTextCommand implements UndoableCommand { private document: TextDocument; private start: number; private end: number; private deletedText: string = ""; constructor(document: TextDocument, start: number, end: number) { this.document = document; this.start = start; this.end = end; } execute(): void { // Capture the text that will be deleted this.deletedText = this.document.getText(this.start, this.end); this.document.delete(this.start, this.end); } undo(): void { // Re-insert the captured text this.document.insert(this.start, this.deletedText); } getDescription(): string { const preview = this.deletedText.length > 20 ? this.deletedText.substring(0, 20) + "..." : this.deletedText; return `Delete "${preview}"`; }}123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
// Capture entire object state—simpler but uses more memoryinterface Memento { getState(): unknown; getTimestamp(): Date;} interface Originator { createMemento(): Memento; restoreFromMemento(memento: Memento): void;} class EditorMemento implements Memento { private readonly state: string; private readonly cursorPosition: number; private readonly selectionRange: [number, number] | null; private readonly timestamp: Date; constructor( state: string, cursorPosition: number, selectionRange: [number, number] | null ) { this.state = state; this.cursorPosition = cursorPosition; this.selectionRange = selectionRange; this.timestamp = new Date(); } getState(): { text: string; cursor: number; selection: [number, number] | null } { return { text: this.state, cursor: this.cursorPosition, selection: this.selectionRange }; } getTimestamp(): Date { return this.timestamp; }} class TextEditor implements Originator { private text: string = ""; private cursorPosition: number = 0; private selectionRange: [number, number] | null = null; createMemento(): EditorMemento { return new EditorMemento( this.text, this.cursorPosition, this.selectionRange ); } restoreFromMemento(memento: EditorMemento): void { const state = memento.getState(); this.text = state.text; this.cursorPosition = state.cursor; this.selectionRange = state.selection; } // Editor operations... type(char: string): void { this.text = this.text.substring(0, this.cursorPosition) + char + this.text.substring(this.cursorPosition); this.cursorPosition++; }} // Command using memento for undoclass EditCommandWithMemento implements UndoableCommand { private editor: TextEditor; private action: () => void; private description: string; private beforeMemento?: EditorMemento; private afterMemento?: EditorMemento; constructor( editor: TextEditor, action: () => void, description: string ) { this.editor = editor; this.action = action; this.description = description; } execute(): void { this.beforeMemento = this.editor.createMemento(); this.action(); this.afterMemento = this.editor.createMemento(); } undo(): void { if (this.beforeMemento) { this.editor.restoreFromMemento(this.beforeMemento); } } // Note: We could support redo by restoring afterMemento // This avoids re-executing the action (useful for non-deterministic ops) getDescription(): string { return this.description; }}| Strategy | Memory Usage | Complexity | Best For |
|---|---|---|---|
| Changed State Only | Minimal—only deltas | Higher—each command knows its reversal | Performance-critical apps, simple operations |
| Full Snapshot (Memento) | High—full state per command | Lower—uniform snapshot/restore | Complex state, small objects, prototyping |
| Hybrid: Checkpoint + Deltas | Moderate—periodic snapshots + lightweight deltas | Moderate—batching logic needed | Large documents, memory-constrained environments |
Full snapshots (Memento) are ideal when: (1) the object's state is small, (2) computing the reverse operation is complex or error-prone, (3) operations may not be deterministic (e.g., random number generation), or (4) you need to support jumping to arbitrary history points rather than just sequential undo.
Real-world undo implementations face challenges beyond the basic stack model. Let's examine several complex scenarios and their solutions.
123456789101112131415161718192021222324252627282930313233343536373839404142
// Multiple operations treated as a single undoable unitclass MacroCommand implements UndoableCommand { private commands: UndoableCommand[] = []; private name: string; constructor(name: string, commands: UndoableCommand[]) { this.name = name; this.commands = [...commands]; } execute(): void { // Execute all sub-commands in order for (const command of this.commands) { command.execute(); } } undo(): void { // Undo in REVERSE order—critical for correctness! for (let i = this.commands.length - 1; i >= 0; i--) { this.commands[i].undo(); } } getDescription(): string { return this.name; }} // Usage: Format paragraph (bold + italic + indent)const formatParagraphMacro = new MacroCommand( "Format Paragraph", [ new ApplyBoldCommand(paragraph), new ApplyItalicCommand(paragraph), new IndentCommand(paragraph, 2), ]); // Single undo reverses all three operationsundoManager.executeCommand(formatParagraphMacro);undoManager.undo(); // Reverts indent, italic, and bold togetherSome applications allow undoing operations other than the most recent—for example, undoing a specific edit from 10 steps ago while keeping subsequent edits.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
// Commands track dependencies to enable safe selective undointerface SelectiveUndoableCommand extends UndoableCommand { getId(): string; getDependencies(): string[]; // IDs of commands this depends on affectsRegion?(): { start: number; end: number }; // For conflict detection} class SelectiveUndoManager { private history: SelectiveUndoableCommand[] = []; private undone: Set<string> = new Set(); executeCommand(command: SelectiveUndoableCommand): void { command.execute(); this.history.push(command); } /** * Undo a specific command by ID. * Checks for conflicts with subsequent commands. */ undoCommand(commandId: string): { success: boolean; conflicts?: string[] } { const index = this.history.findIndex(c => c.getId() === commandId); if (index === -1) { return { success: false }; } const command = this.history[index]; // Check if any subsequent command depends on this one const conflicts = this.findConflicts(command, index); if (conflicts.length > 0) { return { success: false, conflicts: conflicts.map(c => c.getDescription()) }; } // Safe to undo command.undo(); this.undone.add(commandId); return { success: true }; } private findConflicts( command: SelectiveUndoableCommand, index: number ): SelectiveUndoableCommand[] { const conflicts: SelectiveUndoableCommand[] = []; const region = command.affectsRegion?.(); // Check all subsequent commands for (let i = index + 1; i < this.history.length; i++) { const subsequent = this.history[i]; // Skip if already undone if (this.undone.has(subsequent.getId())) { continue; } // Check explicit dependency if (subsequent.getDependencies().includes(command.getId())) { conflicts.push(subsequent); continue; } // Check region overlap (for text operations) const subsequentRegion = subsequent.affectsRegion?.(); if (region && subsequentRegion && this.regionsOverlap(region, subsequentRegion)) { conflicts.push(subsequent); } } return conflicts; } private regionsOverlap( a: { start: number; end: number }, b: { start: number; end: number } ): boolean { return a.start < b.end && b.start < a.end; }}Some operations span multiple systems—a database write, an API call, a file save. These require distributed undo considerations.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
// Command that coordinates undo across multiple systemsinterface DistributedUndoableCommand extends UndoableCommand { prepare(): Promise<boolean>; // Validate undo is possible executeDistributed(): Promise<void>; // Execute with rollback support undoDistributed(): Promise<void>; getParticipants(): string[]; // Systems involved} class SaveToCloudCommand implements DistributedUndoableCommand { private document: Document; private localBackup: string = ""; private cloudBackup: string = ""; private localPath: string; private cloudPath: string; private executed: boolean = false; constructor(document: Document, localPath: string, cloudPath: string) { this.document = document; this.localPath = localPath; this.cloudPath = cloudPath; } async prepare(): Promise<boolean> { // Capture current state of both systems try { this.localBackup = await fs.readFile(this.localPath, 'utf-8'); this.cloudBackup = await cloudStorage.download(this.cloudPath); return true; } catch { return false; } } async executeDistributed(): Promise<void> { const content = this.document.serialize(); try { // Execute both operations await fs.writeFile(this.localPath, content); await cloudStorage.upload(this.cloudPath, content); this.executed = true; } catch (error) { // Rollback local if cloud fails await fs.writeFile(this.localPath, this.localBackup); throw error; } } async undoDistributed(): Promise<void> { if (!this.executed) return; // Restore both systems to backed-up state await fs.writeFile(this.localPath, this.localBackup); await cloudStorage.upload(this.cloudPath, this.cloudBackup); } // ... sync wrappers for standard interface execute(): void { // In practice, would need to handle async } undo(): void { // In practice, would need to handle async } getParticipants(): string[] { return ['local-filesystem', 'cloud-storage']; } getDescription(): string { return `Save to cloud (${this.cloudPath})`; }}Full distributed undo requires Two-Phase Commit or Saga patterns. Network failures, partial writes, and concurrent modifications all complicate storage and rollback. For many applications, limiting undo scope to in-memory state and warning users about un-undoable operations is more practical.
Modern applications support unlimited (or very deep) undo history. This requires careful attention to memory management, persistence, and user interface design.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
interface CommandDisposable extends UndoableCommand { dispose(): void; // Cleanup when evicted from history} class ProductionUndoManager { private undoStack: CommandDisposable[] = []; private redoStack: CommandDisposable[] = []; private maxUndoLevels: number; private maxMemoryBytes: number; private currentMemoryUsage: number = 0; // Event callbacks for UI updates private onHistoryChange?: () => void; constructor(options: { maxUndoLevels?: number; maxMemoryBytes?: number; onHistoryChange?: () => void; } = {}) { this.maxUndoLevels = options.maxUndoLevels ?? 100; this.maxMemoryBytes = options.maxMemoryBytes ?? 50 * 1024 * 1024; // 50MB this.onHistoryChange = options.onHistoryChange; } executeCommand(command: CommandDisposable, sizeBytes: number = 0): void { command.execute(); this.undoStack.push(command); this.currentMemoryUsage += sizeBytes; // Clear redo and dispose redo commands for (const redoCommand of this.redoStack) { redoCommand.dispose(); } this.redoStack = []; // Enforce limits this.enforceHistoryLimits(); this.notifyChange(); } private enforceHistoryLimits(): void { // Enforce count limit while (this.undoStack.length > this.maxUndoLevels) { const oldest = this.undoStack.shift(); oldest?.dispose(); } // Enforce memory limit (simplified—real impl would track per-command size) while (this.currentMemoryUsage > this.maxMemoryBytes && this.undoStack.length > 1) { const oldest = this.undoStack.shift(); oldest?.dispose(); // Would subtract this command's size from currentMemoryUsage } } undo(levels: number = 1): number { let undone = 0; for (let i = 0; i < levels; i++) { const command = this.undoStack.pop(); if (!command) break; command.undo(); this.redoStack.push(command); undone++; } if (undone > 0) { this.notifyChange(); } return undone; } redo(levels: number = 1): number { let redone = 0; for (let i = 0; i < levels; i++) { const command = this.redoStack.pop(); if (!command) break; command.execute(); this.undoStack.push(command); redone++; } if (redone > 0) { this.notifyChange(); } return redone; } // Jump to specific point in history undoTo(targetUndoDepth: number): void { const currentDepth = this.undoStack.length; if (targetUndoDepth < currentDepth) { this.undo(currentDepth - targetUndoDepth); } else if (targetUndoDepth > currentDepth) { this.redo(targetUndoDepth - currentDepth); } } // Clear all history and dispose commands clearHistory(): void { for (const command of [...this.undoStack, ...this.redoStack]) { command.dispose(); } this.undoStack = []; this.redoStack = []; this.currentMemoryUsage = 0; this.notifyChange(); } // History query methods for UI getHistorySnapshot(): { undoDescriptions: string[]; redoDescriptions: string[]; canUndo: boolean; canRedo: boolean; } { return { undoDescriptions: this.undoStack.map(c => c.getDescription()), redoDescriptions: this.redoStack.map(c => c.getDescription()).reverse(), canUndo: this.undoStack.length > 0, canRedo: this.redoStack.length > 0, }; } private notifyChange(): void { this.onHistoryChange?.(); }}For very deep undo or large state, maintaining deltas for every operation becomes expensive. Checkpointing creates periodic full snapshots, allowing older commands to be discarded.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
class CheckpointUndoManager<TState> { private checkpoints: Array<{ state: TState; timestamp: Date; commandIndex: number; }> = []; private commands: UndoableCommand[] = []; private currentIndex: number = -1; private checkpointInterval: number = 50; // Checkpoint every 50 commands private cloneState: (state: TState) => TState; private applyCommand: (state: TState, command: UndoableCommand) => TState; private currentState: TState; constructor( initialState: TState, cloneState: (state: TState) => TState, applyCommand: (state: TState, command: UndoableCommand) => TState ) { this.currentState = cloneState(initialState); this.cloneState = cloneState; this.applyCommand = applyCommand; // Create initial checkpoint this.createCheckpoint(); } executeCommand(command: UndoableCommand): void { // Remove any redo history this.commands = this.commands.slice(0, this.currentIndex + 1); // Execute and record command.execute(); this.commands.push(command); this.currentIndex++; // Create checkpoint if needed if (this.currentIndex % this.checkpointInterval === 0) { this.createCheckpoint(); this.pruneOldCommands(); } } private createCheckpoint(): void { this.checkpoints.push({ state: this.cloneState(this.currentState), timestamp: new Date(), commandIndex: this.currentIndex, }); } private pruneOldCommands(): void { // Keep last 2 checkpoints, discard older commands if (this.checkpoints.length > 2) { const oldestKeep = this.checkpoints[this.checkpoints.length - 2]; // Commands older than oldestKeep can be discarded // (In practice, need to adjust indices) } } undoTo(targetIndex: number): void { if (targetIndex < -1 || targetIndex >= this.commands.length) { return; } if (targetIndex < this.currentIndex) { // Going backward: find nearest checkpoint, then replay const checkpoint = this.findNearestCheckpoint(targetIndex); this.currentState = this.cloneState(checkpoint.state); // Replay commands from checkpoint to target for (let i = checkpoint.commandIndex + 1; i <= targetIndex; i++) { this.currentState = this.applyCommand( this.currentState, this.commands[i] ); } } else { // Going forward: replay from current for (let i = this.currentIndex + 1; i <= targetIndex; i++) { this.currentState = this.applyCommand( this.currentState, this.commands[i] ); } } this.currentIndex = targetIndex; } private findNearestCheckpoint( targetIndex: number ): { state: TState; timestamp: Date; commandIndex: number } { // Find checkpoint at or before targetIndex for (let i = this.checkpoints.length - 1; i >= 0; i--) { if (this.checkpoints[i].commandIndex <= targetIndex) { return this.checkpoints[i]; } } return this.checkpoints[0]; }}Checkpointing trades storage space for undo flexibility. More frequent checkpoints allow faster random-access undo but use more memory. The optimal interval depends on state size, undo frequency, and typical undo depth.
Collaborative editing (Google Docs, Figma, VS Code Live Share) introduces unique undo challenges. When multiple users edit simultaneously, whose undo is whose? What if Alice undoes an operation that Bob's subsequent edit depends on?
In collaborative systems, users expect their "undo" to reverse their own actions—not affect other users' work. But operations from different users may be interleaved in the shared history.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
interface CollaborativeCommand { id: string; userId: string; timestamp: number; execute(): void; undo(): void; transform(other: CollaborativeCommand): CollaborativeCommand; getDescription(): string;} class CollaborativeUndoManager { private globalHistory: CollaborativeCommand[] = []; private userUndoStacks: Map<string, CollaborativeCommand[]> = new Map(); private userRedoStacks: Map<string, CollaborativeCommand[]> = new Map(); executeCommand(command: CollaborativeCommand): void { command.execute(); this.globalHistory.push(command); // Add to this user's undo stack let userStack = this.userUndoStacks.get(command.userId); if (!userStack) { userStack = []; this.userUndoStacks.set(command.userId, userStack); } userStack.push(command); // Clear this user's redo this.userRedoStacks.set(command.userId, []); } undoForUser(userId: string): boolean { const userStack = this.userUndoStacks.get(userId); if (!userStack || userStack.length === 0) { return false; } const commandToUndo = userStack.pop()!; // The key challenge: commandToUndo may have been transformed // by subsequent operations from other users. // We need to compute the "current" version of this undo. const transformedUndo = this.computeCurrentUndoEffect(commandToUndo); transformedUndo.undo(); // Track for redo let redoStack = this.userRedoStacks.get(userId); if (!redoStack) { redoStack = []; this.userRedoStacks.set(userId, redoStack); } redoStack.push(commandToUndo); return true; } /** * Transform the undo operation against all subsequent operations. * This is where Operational Transformation (OT) or CRDT logic applies. */ private computeCurrentUndoEffect( originalCommand: CollaborativeCommand ): CollaborativeCommand { const originalIndex = this.globalHistory.indexOf(originalCommand); let currentCommand = originalCommand; // Transform against all subsequent operations for (let i = originalIndex + 1; i < this.globalHistory.length; i++) { const subsequent = this.globalHistory[i]; if (subsequent.userId !== originalCommand.userId) { currentCommand = currentCommand.transform(subsequent); } } return currentCommand; }} // Example: Transform insert position after another insertclass InsertTextOT implements CollaborativeCommand { id: string; userId: string; timestamp: number; private document: TextDocument; private position: number; private text: string; constructor( userId: string, document: TextDocument, position: number, text: string ) { this.id = crypto.randomUUID(); this.userId = userId; this.timestamp = Date.now(); this.document = document; this.position = position; this.text = text; } execute(): void { this.document.insert(this.position, this.text); } undo(): void { this.document.delete(this.position, this.position + this.text.length); } /** * Transform this operation against a concurrent operation. * Returns a new command that achieves the same intent * assuming 'other' has already been applied. */ transform(other: CollaborativeCommand): CollaborativeCommand { if (other instanceof InsertTextOT) { // If other inserted before our position, shift our position if (other.position <= this.position) { return new InsertTextOT( this.userId, this.document, this.position + other.text.length, // Shifted! this.text ); } } return this; // No transformation needed } getDescription(): string { return `Insert "${this.text}" at ${this.position}`; }}Implementing truly correct collaborative undo requires Operational Transformation (OT) or Conflict-free Replicated Data Types (CRDTs). These are complex, subtle, and easy to get wrong. For most applications, limiting undo scope to local drafts before collaboration syncing is more practical.
A technically correct undo system can still provide poor user experience. Consider these UX patterns:
How granular should undo be? Should each keystroke be undoable, or should we group related edits?
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
interface CoalescingPolicy { shouldCoalesce(current: UndoableCommand, next: UndoableCommand): boolean; coalesce(commands: UndoableCommand[]): UndoableCommand;} class TypingCoalescer implements CoalescingPolicy { private maxTimeDiff = 1000; // 1 second shouldCoalesce( current: UndoableCommand, next: UndoableCommand ): boolean { // Only coalesce typing commands if (!(current instanceof TypeCharCommand) || !(next instanceof TypeCharCommand)) { return false; } // Check if they're consecutive positions if (next.position !== current.position + current.text.length) { return false; } // Check if within time window if (next.timestamp - current.timestamp > this.maxTimeDiff) { return false; } return true; } coalesce(commands: UndoableCommand[]): UndoableCommand { const typingCommands = commands as TypeCharCommand[]; const first = typingCommands[0]; const combinedText = typingCommands.map(c => c.text).join(''); return new InsertTextCommand( first.document, first.position, combinedText ); }} class CoalescingUndoManager { private pendingCommand: UndoableCommand | null = null; private coalescingPolicy: CoalescingPolicy; private baseManager: UndoRedoManager; private flushTimeout: NodeJS.Timeout | null = null; constructor( policy: CoalescingPolicy, baseManager: UndoRedoManager ) { this.coalescingPolicy = policy; this.baseManager = baseManager; } executeCommand(command: UndoableCommand): void { command.execute(); if (this.pendingCommand && this.coalescingPolicy.shouldCoalesce(this.pendingCommand, command)) { // Coalesce with pending this.pendingCommand = this.coalescingPolicy.coalesce([ this.pendingCommand, command ]); } else { // Flush pending and start new this.flush(); this.pendingCommand = command; } // Auto-flush after a delay if (this.flushTimeout) { clearTimeout(this.flushTimeout); } this.flushTimeout = setTimeout(() => this.flush(), 500); } flush(): void { if (this.pendingCommand) { this.baseManager.executeCommand(this.pendingCommand); this.pendingCommand = null; } } undo(): boolean { this.flush(); return this.baseManager.undo(); }}Undo granularity is subjective. What feels right depends on the domain. Text editors group typing; photo editors group brush strokes; CAD applications may undo individual clicks. Test with real users to find the right balance.
We've explored the full spectrum of undo/redo implementation using the Command Pattern. Let's consolidate the key concepts:
What's Next:
In the next page, we'll explore command queues and logging—using command objects for batch execution, async processing, operation logging, audit trails, and replay for debugging or analytics.
You now understand how to implement robust undo/redo systems using the Command Pattern. From basic dual-stack management to complex collaborative scenarios, the key is treating operations as first-class objects that encapsulate both execution and reversal logic.