Memento pattern: Design Patterns lessons from production
You've all been there: a user deletes three rows of critical data, then realizes it was a mistake. Without a proper restoration mechanism, you're left scrambling. The Memento pattern solves this by capturing and storing an object's state so you can restore it later—a technique we've relied on countless times across our e-commerce, trading, and SaaS platforms.
In this article, we'll explore how to implement the Memento pattern effectively in production systems, from undo-redo mechanics in editors to creating snapshots before risky operations. We'll also cover how immutable state design keeps mementos lightweight and discuss strategies for managing unbounded history growth.
Undo and redo in editors
Text editors and form builders represent the most familiar use case for the Memento pattern. Every keystroke, every drag-and-drop, every formatting change becomes a potential undo checkpoint. Rather than storing entire document copies, modern editors capture only what changed and maintain a stack of those changes.
Here's how we typically structure this in a real editor application:
// Core memento interfaces
interface EditorMemento {
content: string;
cursorPosition: number;
timestamp: number;
}
interface EditorState {
content: string;
cursorPosition: number;
}
class EditorHistory {
private undoStack: EditorMemento[] = [];
private redoStack: EditorMemento[] = [];
private maxHistorySize = 100;
saveState(state: EditorState): void {
const memento: EditorMemento = {
content: state.content,
cursorPosition: state.cursorPosition,
timestamp: Date.now(),
};
this.undoStack.push(memento);
this.redoStack = []; // Clear redo when new change occurs
if (this.undoStack.length > this.maxHistorySize) {
this.undoStack.shift(); // Remove oldest
}
}
undo(): EditorState | null {
if (this.undoStack.length === 0) return null;
const current = this.undoStack.pop();
if (current) {
this.redoStack.push(current);
const previous = this.undoStack[this.undoStack.length - 1];
return previous ? this.restoreState(previous) : this.getEmptyState();
}
return null;
}
redo(): EditorState | null {
if (this.redoStack.length === 0) return null;
const memento = this.redoStack.pop();
if (memento) {
this.undoStack.push(memento);
return this.restoreState(memento);
}
return null;
}
private restoreState(memento: EditorMemento): EditorState {
return {
content: memento.content,
cursorPosition: memento.cursorPosition,
};
}
private getEmptyState(): EditorState {
return {
content: '',
cursorPosition: 0,
};
}
}
class Editor {
private history: EditorHistory;
private state: EditorState;
constructor() {
this.history = new EditorHistory();
this.state = { content: '', cursorPosition: 0 };
this.history.saveState(this.state);
}
insertText(text: string, position: number): void {
const before = this.state.content.substring(0, position);
const after = this.state.content.substring(position);
this.state.content = before + text + after;
this.state.cursorPosition = position + text.length;
this.history.saveState(this.state);
}
undo(): void {
const restored = this.history.undo();
if (restored) {
this.state = restored;
}
}
redo(): void {
const restored = this.history.redo();
if (restored) {
this.state = restored;
}
}
getState(): EditorState {
return { ...this.state };
}
}In production, we've found that batching related operations into a single memento significantly improves performance. If a user pastes 100 lines of text, we capture that as one undo point rather than 100 separate ones. This keeps the history stack manageable while still providing intuitive undo behavior.
Snapshots before risky operations
Beyond editors, snapshots prove invaluable before executing operations that might fail or need reversal. In our e-commerce platform, we snapshot cart state before applying a complex discount. In trading systems, we capture account balances before executing batch operations.
Consider a data import workflow where multiple transformations occur sequentially:
interface DataSnapshot {
records: Record[];
importedAt: number;
status: 'pending' | 'processing' | 'completed' | 'failed';
}
class DataImportCaretaker {
private snapshot: DataSnapshot | null = null;
captureSnapshot(records: Record[]): void {
this.snapshot = {
records: JSON.parse(JSON.stringify(records)), // Deep copy
importedAt: Date.now(),
status: 'pending',
};
}
executeImportPhase(
originator: DataImporter,
phaseName: string
): boolean {
try {
console.log(`Starting phase: ${phaseName}`);
originator.executePhase(phaseName);
return true;
} catch (error) {
console.error(`Phase ${phaseName} failed:`, error);
if (this.snapshot) {
originator.restoreFromSnapshot(this.snapshot);
console.log('Restored from snapshot');
}
return false;
}
}
}
class DataImporter {
private data: Record[];
constructor(initialData: Record[]) {
this.data = initialData;
}
executePhase(phaseName: string): void {
switch (phaseName) {
case 'validation':
this.validateRecords();
break;
case 'normalization':
this.normalizeData();
break;
case 'enrichment':
this.enrichRecords();
break;
default:
throw new Error(`Unknown phase: ${phaseName}`);
}
}
private validateRecords(): void {
// Validation logic—may throw
this.data = this.data.filter((r) => r.isValid);
}
private normalizeData(): void {
// Normalization—may throw
this.data = this.data.map((r) => ({
...r,
email: r.email.toLowerCase().trim(),
}));
}
private enrichRecords(): void {
// Enrichment—may throw
this.data = this.data.map((r) => ({
...r,
enrichedAt: new Date().toISOString(),
}));
}
restoreFromSnapshot(snapshot: DataSnapshot): void {
this.data = JSON.parse(JSON.stringify(snapshot.records));
}
getData(): Record[] {
return this.data;
}
}
// Usage
const importer = new DataImporter(loadInitialData());
const caretaker = new DataImportCaretaker();
caretaker.captureSnapshot(importer.getData());
const phases = ['validation', 'normalization', 'enrichment'];
for (const phase of phases) {
if (!caretaker.executeImportPhase(importer, phase)) {
console.log('Import failed, data rolled back');
break;
}
}This approach eliminates complex rollback logic. When a phase fails, we don't need conditional logic to undo previous steps—we simply restore the memento. We've used this pattern extensively in payment processing pipelines and multi-step user onboarding flows.
Immutable state makes mementos cheap
A critical performance consideration: if you're deep-copying entire objects on every checkpoint, mementos become expensive. Immutable state structures change this equation dramatically.
When using immutable patterns, a "new" state is actually just a reference to an existing object plus a small delta. This is where persistent data structures shine:
// Using immutable.js or similar
import * as Immutable from 'immutable';
interface FormState {
fields: Immutable.Map;
validationErrors: Immutable.Map;
isDirty: boolean;
}
class FormHistory {
private undoStack: FormState[] = [];
private maxHistorySize = 50;
// With immutable structures, this is O(1) in most cases
// rather than O(n) deep copy
captureState(state: FormState): void {
this.undoStack.push(state);
if (this.undoStack.length > this.maxHistorySize) {
this.undoStack.shift();
}
}
restore(index: number): FormState | null {
if (index < 0 || index >= this.undoStack.length) return null;
return this.undoStack[index];
}
}
class FormManager {
private state: FormState;
private history: FormHistory;
constructor() {
this.state = {
fields: Immutable.Map(),
validationErrors: Immutable.Map(),
isDirty: false,
};
this.history = new FormHistory();
this.history.captureState(this.state);
}
updateField(fieldName: string, value: string): void {
// Creates a new FormState without copying the entire structure
this.state = {
...this.state,
fields: this.state.fields.set(fieldName, value),
isDirty: true,
};
this.history.captureState(this.state);
}
getState(): FormState {
return this.state;
}
}The beauty here is that we're not duplicating data. Immutable structures use structural sharing under the hood, so storing hundreds of form states uses minimal memory. We've seen this reduce memory footprint by 90% compared to naive deep-copy approaches in complex workflow applications.
Limit history size
Unbounded history will eventually consume all available memory. We need practical strategies to manage growth without losing critical checkpoints.
The simplest approach is a fixed-size ring buffer:
class BoundedHistory {
private buffer: (T | null)[];
private currentIndex = -1;
private isFull = false;
constructor(private maxSize: number) {
this.buffer = new Array(maxSize).fill(null);
}
push(item: T): void {
this.currentIndex = (this.currentIndex + 1) % this.maxSize;
this.buffer[this.currentIndex] = item;
if (this.currentIndex === this.maxSize - 1) {
this.isFull = true;
}
}
peek(): T | null {
return this.buffer[this.currentIndex];
}
size(): number {
return this.isFull ? this.maxSize : this.currentIndex + 1;
}
}For more sophisticated scenarios, we use time-based or event-based pruning. In a long-running trading session, we might keep high-resolution history for the last 10 minutes and then compress older entries:
interface TimestampedMemento {
data: T;
timestamp: number;
}
class SmartHistory {
private history: TimestampedMemento[] = [];
private readonly recentWindow = 10 * 60 * 1000; // 10 minutes
private readonly maxTotalSize = 1000;
add(item: T): void {
this.history.push({
data: item,
timestamp: Date.now(),
});
this.prune();
}
private prune(): void {
if (this.history.length <= this.maxTotalSize) return;
const now = Date.now();
const cutoff = now - this.recentWindow;
// Keep all recent entries, sample older ones
const recentEntries = this.history.filter(
(m) => m.timestamp > cutoff
);
const olderEntries = this.history.filter(
(m) => m.timestamp <= cutoff
);
// Keep every 5th older entry
const sampledOlder = olderEntries.filter(
(_, i) => i % 5 === 0
);
this.history = [...sampledOlder, ...recentEntries];
}
size(): number {
return this.history.length;
}
}The key is monitoring memory usage and adjusting strategy based on production telemetry. We've found that most users only need to undo the last 20–50 actions anyway—limiting to 100 entries usually satisfies 95% of use cases.
Common mistakes
- Forgetting to clear redo stack on new action: After a user undoes then performs a new action, the redo stack should reset. If you don't, users can redo changes that shouldn't exist.
- Storing references instead of values: If your memento holds a reference to a mutable object, restoring that memento restores the same reference. Any later mutation affects all "historical" states. Always copy or use immutable structures.
- No upper bound on history: Without limits, history grows indefinitely. Set a reasonable cap and implement pruning logic before it becomes a production incident.
- Capturing too frequently: Saving state on every keystroke creates massive stacks. Batch related operations or use debouncing to reduce checkpoint frequency.
- Ignoring serialization costs: Deep copying large objects on every save is expensive. Consider structural sharing or lazy copying strategies.
Key takeaways
- The Memento pattern captures state snapshots, enabling undo-redo and rollback capabilities without complex conditional logic.
- Use mementos in editors for keystroke history and in business workflows as insurance before risky multi-step operations.
- Immutable state structures make mementos cheap to store through structural sharing; pair this with the Memento pattern for scalable history.
- Always set a maximum history size and implement pruning strategies to prevent unbounded memory growth in production.
- Clear redo stacks after new actions, copy state values rather than holding references, and batch related operations into single mementos for optimal performance.






Comments
Loading comments…