StackPractices
intermediate By Mathias Paulenko

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

Topics: design

The Memento pattern captures and externalizes an object’s internal state so it can be restored later, without exposing that state to the outside world. Where Command records the operations needed to undo a change, Memento just remembers what the object looked like — that’s why it’s the natural fit for editor undo stacks, game checkpoints, and multi-step form drafts.

When to Use This

  • You need to restore an object’s full state, not just reverse the last operation.
  • The object’s internals are complex and must stay private — the undo feature can’t reach inside.
  • Snapshots have to survive serialization: saved to disk, sent over a network, or kept per session.
  • A wizard or editor needs “restore everything to step N”, not “undo action N”.

Skip it when state is small and operations are cheap to invert — for “delete last word”, a Command stack costs less memory than copying the whole document on every keystroke. It also loses to event sourcing when you need an audit trail of why each change happened, not just where you ended up.

Problem

A drawing app needs undo, but its document stores shapes as nested coordinate and style objects. Exposing getters for every field breaks encapsulation, and recording operations doesn’t work either: external tools can mutate shapes directly, so there’s no reliable “inverse operation” to replay backwards.

Solution

// memento/EditorMemento.ts
interface EditorMemento {
  getState(): string;
}

class TextEditor {
  private content = '';
  private cursorPosition = 0;
  private selectionRange: [number, number] = [0, 0];

  type(text: string): void {
    const before = this.content.slice(0, this.cursorPosition);
    const after = this.content.slice(this.cursorPosition);
    this.content = before + text + after;
    this.cursorPosition += text.length;
    this.selectionRange = [this.cursorPosition, this.cursorPosition];
  }

  delete(): void {
    const [start, end] = this.selectionRange;
    if (start === end && start > 0) {
      this.content = this.content.slice(0, start - 1) + this.content.slice(start);
      this.cursorPosition = start - 1;
    } else {
      this.content = this.content.slice(0, start) + this.content.slice(end);
      this.cursorPosition = start;
    }
    this.selectionRange = [this.cursorPosition, this.cursorPosition];
  }

  // Create snapshot
  save(): EditorMemento {
    return new EditorSnapshot(
      this.content,
      this.cursorPosition,
      this.selectionRange
    );
  }

  // Restore from snapshot
  restore(memento: EditorMemento): void {
    const snapshot = memento as EditorSnapshot;
    this.content = snapshot.getContent();
    this.cursorPosition = snapshot.getCursor();
    this.selectionRange = snapshot.getSelection();
  }

  getContent(): string {
    return this.content;
  }
}

// Memento implementation (opaque to clients)
class EditorSnapshot implements EditorMemento {
  constructor(
    private content: string,
    private cursor: number,
    private selection: [number, number]
  ) {}

  getState(): string {
    return JSON.stringify({ content: this.content, cursor: this.cursor, selection: this.selection });
  }

  getContent(): string { return this.content; }
  getCursor(): number { return this.cursor; }
  getSelection(): [number, number] { return this.selection; }
}

// Caretaker manages history
class EditorHistory {
  private history: EditorMemento[] = [];
  private currentIndex = -1;

  backup(editor: TextEditor): void {
    this.history = this.history.slice(0, this.currentIndex + 1);
    this.history.push(editor.save());
    this.currentIndex++;
  }

  undo(editor: TextEditor): void {
    if (this.currentIndex <= 0) return;
    this.currentIndex--;
    editor.restore(this.history[this.currentIndex]);
  }

  redo(editor: TextEditor): void {
    if (this.currentIndex >= this.history.length - 1) return;
    this.currentIndex++;
    editor.restore(this.history[this.currentIndex]);
  }
}

// Usage
const editor = new TextEditor();
const history = new EditorHistory();

history.backup(editor);
editor.type('Hello');
history.backup(editor);
editor.type(' World');

console.log(editor.getContent()); // "Hello World"

history.undo(editor);
console.log(editor.getContent()); // "Hello"

history.redo(editor);
console.log(editor.getContent()); // "Hello World"
flowchart diagram: Client

The Caretaker holds the mementos but treats them as opaque handles — only the Originator that created a snapshot can read it back.

Companion code: the memento-pattern-state companion repo has both examples on this page runnable with Node ≥ 22.18, plus tests covering undo, checkpoint isolation, and wizard restore.

Variation: Game Checkpoint System

// memento/GameCheckpoint.ts
interface Memento {
  getState(): string;
}

class GameState {
  private level = 1;
  private health = 100;
  private inventory: string[] = [];

  createCheckpoint(): Memento {
    return new GameCheckpoint(this.level, this.health, [...this.inventory]);
  }

  loadCheckpoint(checkpoint: Memento): void {
    const cp = checkpoint as GameCheckpoint;
    this.level = cp.getLevel();
    this.health = cp.getHealth();
    this.inventory = cp.getInventory();
  }
}

class GameCheckpoint implements Memento {
  constructor(
    private level: number,
    private health: number,
    private inventory: string[]
  ) {}

  getState(): string {
    return JSON.stringify({ level: this.level, health: this.health });
  }

  getLevel(): number { return this.level; }
  getHealth(): number { return this.health; }
  getInventory(): string[] { return [...this.inventory]; }
}

Note the details here: createCheckpoint spreads the inventory array ([...this.inventory]) so the snapshot doesn’t share it with live state, and getInventory returns a copy so callers can’t mutate the checkpoint after loading. If loadCheckpoint received the raw array back, the next inventory.push() would silently rewrite the saved checkpoint.

How It Works

  1. Originator — the object whose state matters. Only it can create and read mementos.
  2. Memento — the snapshot. To the Caretaker it’s an opaque token; internally it carries whatever the Originator needs.
  3. Caretaker — owns the history stack. It decides when to snapshot (keystroke, step change, timer), never what.
  4. Client — triggers backup() and undo()/redo() through the Caretaker.

This is what the GoF book calls the narrow/wide interface split: the Caretaker sees a narrow interface (store this, give it back), while the Originator sees the wide one (full field access). In TypeScript you get it for free — the Caretaker only knows the EditorMemento interface, and the cast inside restore stays private to the Originator.

Consequences and Trade-offs

Memento trades memory for simplicity. Every snapshot is a deep copy, so cost grows with history depth × state size — fine for a form, painful for a 2 MB document snapshotted per keystroke.

ApproachStoresMemoryBest when
MementoFull state snapshotsHigh (deep copies)State is hard to recompute; you need “restore to step N”
CommandOperations + inversesLowActions are cheap to reverse; undo granularity matters
PrototypeCloned object graphMediumYou want a copy to continue from, not a history

There’s a fourth option when state is already immutable (Redux-style): keep references to old state trees instead of copying. That’s a memento history that costs almost nothing, because unchanged branches are shared between versions. It also dodges the biggest Memento foot-gun: a snapshot that nobody can accidentally mutate.

Production Considerations

  • Cap the history. The form example below keeps 50 snapshots; an editor with a large document does the same math and picks a limit that fits its memory budget.
  • Share structure. structuredClone copies everything every time; persistent data structures (Immutable.js, Immer’s produce + frozen past states) turn a “snapshot” into a pointer to an old root.
  • Version your mementos. Snapshots persisted across deployments will outlive your schema — a renamed field makes old saves unrestorable. Include a version key and migrate on load.
  • Don’t snapshot per keystroke. JSON.stringify on a big state per keystroke is a GC storm. Snapshot on save, on pause, or on meaningful transitions — or store deltas between snapshots instead of full states.

Common Mistakes

  • Mutable mementos. Returning the live fields object instead of a copy lets later edits mutate “old” snapshots — the history corrupts itself silently.
  • Shallow copies. Same bug, subtler: you clone the top level but nested arrays and objects stay shared.
  • Caretaker reading mementos. Once the Caretaker inspects contents, undo logic leaks out of the Originator and every state change breaks two places.
  • Serializing class instances. Snapshots saved as live object references (or toJSON tied to current class shapes) fail the moment the schema changes. Serialize plain data with a version field.

Troubleshooting

  • Undo restores state but the UI doesn’t update: the restore worked — the view wasn’t notified. Emit a change event from the Originator or return the restored state so the rendering layer can react.
  • History grows until the tab freezes: an unbounded snapshot stack is the usual suspect. Cap history.length, or switch to delta encoding so each entry stores only what changed.
  • The restored object keeps changing on its own: it held references to shared mutable state. Deep-copy on save (structuredClone, or JSON.parse(JSON.stringify()) for plain JSON-safe data) so the snapshot freezes at capture time.
  • Old saved drafts fail to load after a refactor: mementos serialized before a schema change don’t match the new fields. Add version to the snapshot and write a migration for each format bump.

Key Takeaways

  • Memento stores what the object looked like; Command stores what happened. Choose by cost model — deep copies vs. reversible operations — not by habit.
  • Only the Originator reads mementos; the Caretaker is a stack manager and nothing more.
  • The expensive part is the deep copy, so plan history limits, structural sharing, or deltas before it bites.

Advanced Topics

Scenario: Memento for Form Editor

// Memento pattern: save and restore state without exposing internals
type FormState = {
  fields: Record<string, string>;
  errors: Record<string, string>;
  step: number;
}

class FormMemento {
  constructor(private state: FormState) {}
  getState(): FormState { return { ...this.state }; }
}

// Originator: the form
class FormEditor {
  private state: FormState = { fields: {}, errors: {}, step: 0 };

  setField(name: string, value: string) {
    this.state.fields[name] = value;
  }
  setStep(step: number) { this.state.step = step; }
  setErrors(errors: Record<string, string>) { this.state.errors = errors; }

  save(): FormMemento {
    return new FormMemento({ ...this.state, fields: { ...this.state.fields }, errors: { ...this.state.errors } });
  }

  restore(memento: FormMemento) {
    this.state = memento.getState();
  }
  getState(): FormState { return { ...this.state }; }
}

// Caretaker: state history
class FormHistory {
  private history: FormMemento[] = [];
  private maxHistory = 50;

  push(memento: FormMemento) {
    this.history.push(memento);
    if (this.history.length > this.maxHistory) this.history.shift();
  }
  pop(): FormMemento | null { return this.history.pop() || null; }
  canRestore(): boolean { return this.history.length > 0; }
}

// Usage: multi-step wizard form
const form = new FormEditor();
const history = new FormHistory();

// Step 1
form.setField("email", "user@test.com");
form.setStep(1);
history.push(form.save());

// Step 2
form.setField("name", "Alice");
form.setField("phone", "555-1234");
form.setStep(2);
history.push(form.save());

// Step 3
form.setField("address", "123 Main St");
form.setStep(3);

// User clicks "Back"
const prev = history.pop();
if (prev) form.restore(prev);
console.log(form.getState().step); // 2
console.log(form.getState().fields.name); // "Alice"

Lessons:

  • The FormMemento copies fields and errors on save() — a shallow spread on the top level would leave the nested maps shared.
  • maxHistory = 50 bounds memory; the wizard doesn’t need infinite undo.
  • The Caretaker never opens the memento — it pushes and pops opaque objects.
  • Restore is a pop(), not an undo() cursor — a wizard that goes back invalidates the forward path, same as history.slice(0, currentIndex + 1) does in the editor example.

Memento vs Command for undo: which do I use?

Use Memento when you need to restore full state — forms, visual editors, game checkpoints — especially when the change isn’t describable as an operation. Use Command when you undo individual operations and each has a cheap inverse. Memento costs more memory but less bookkeeping: it saves the whole state and doesn’t care how you got there. For wizards and multi-step forms, Memento is the clean fit. For text-level undo where every action has an obvious inverse, Command is lighter.

Common Production Pitfalls

  • Deep-copying the whole document per keystroke. The demo works; the 500 KB production file doesn’t. Measure first, then throttle snapshots or share structure between versions.
  • Snapshots that only your current code can read. Serialize plain data, not instances — a snapshot that needs today’s class shapes becomes unreadable after the first rename.
  • Exposing memento internals “just this once”. One history[3].getState().fields = ... from a shortcut-happy teammate and past and present collapse into the same object.
  • Cramming non-state into the memento. Scroll position, pending network requests, and timers aren’t document state — restoring them replays stale requests or jumps the viewport. Snapshot state, handle the rest in the editor.
  • No version field on persisted snapshots. The first schema change turns every saved draft into a parse error or, worse, a silent partial restore.

Frequently Asked Questions

How is this different from Command?

Command stores operations and reverses them one by one. Memento stores complete state snapshots. Mementos are larger but simpler to implement for complex objects — and they still work when the change can't be expressed as an operation, like an external tool mutating your document.

Can I use this with Redux?

Redux time-travel debugging is a memento history over immutable state — that's why Redux DevTools can jump between snapshots cheaply. With immutable state you store pointers, not copies.

How much memory does a memento history use?

Roughly history depth × state size, since each entry is a full copy. A 200 KB form with 50 snapshots is ~10 MB; structural sharing or delta snapshots bring that down to what's actually different.

Should mementos be serialized to disk?

Yes — that's how "restore draft" survives a browser restart. Serialize plain data with a version field, not class instances, and write migrations when the schema changes.

When should I use the State pattern instead?

State models transitions between named states that carry their own behavior (playing → paused → stopped). Memento only archives values for rollback. If each state needs different behavior, that's State's job — though you can still snapshot whichever state object is active.