GUIDE · Behavioral
Memento
Without violating encapsulation, capture and externalize an object's internal state so the object can be restored to this state later.
At a glance
- INTENT
- Without violating encapsulation, capture and externalize an object's internal state so the object can be restored to this state later.
- PROBLEM
- You need undo / rollback / "restore draft" without every caller poking at private fields — the object should hand out an opaque snapshot and accept it back.
- SAY
- Participants: Originator (creates & restores from mementos), Memento (opaque state snapshot — only the Originator reads inside), Caretaker (stores mementos, never inspects them). Pitch: "save a sealed snapshot now; hand it back later to roll back."
- SEEN IN
- Redux DevTools time travel (array of past states); editor undo via document snapshots;
history.pushState(state)/history.state; DBSAVEPOINT/ROLLBACK TO; Git commits &git stash; form-draft autosave to localStorage; VM/container checkpoints; Jest snapshots (-ish). - VS
- Command stores the OPERATION (and how to invert it) — small, but every op needs an undo; Memento stores the STATE — simple, but can be large. Prototype clones to create a NEW object; Memento captures state to restore the SAME object later.
The idea#
Picture a notebook you are allowed to ruin, plus a stack of sealed photos of the pages. Before you erase a paragraph, you take a photo, tape it shut, and hand it to a friend. Your friend can hold the photo, label it "after version 1", and hand it back later. Your friend cannot open it and rewrite the words inside. When you want the old page, you take the photo back, and you are the only one who knows how to put those words onto the same notebook.
That photo is a memento. The notebook is the originator: it creates the snapshot, and it is the only one who can restore from it. The friend is the caretaker: a history stack that stores the photos and never inspects them. The snapshot is opaque. Outsiders can see the label. They cannot see the title, the body, or the tags.
You need this when you want undo, rollback, or "put my draft back" without letting every caller poke private fields. The object hands out a sealed snapshot and later accepts that same object back. Encapsulation stays intact because the internal state never becomes a public bag of fields for the rest of the program to scribble on.
Why it matters#
The bug this pattern prevents is a restore that lies. If undo means "reach in and copy title, body, and tags from wherever you stashed them," any other function can mutate that stash, forget a field, or paste the words into a different notebook. A sealed photo has one writer and one reader: the originator. A forged object that only looks like a snapshot, a label with no real payload, fails the restore instead of half-applying.
You already use the idea. An editor undo stack is photos of the document. A database savepoint is a photo you can roll back to. A history entry you push before a navigation is a small snapshot of where you were. Time-travel debugging is a list of past photos you can pop back onto the same page.
Command is the pattern this gets confused with. A command stores the operation and how to invert it. "Add the tag" undoes with "remove the tag." That stays small, and it falls apart when every operation needs its own inverse and you forgot one. A memento stores the state. Undo is "put this photo back on the same notebook," which is simple, and the cost is size, because you kept the whole page. Prototype is the other neighbor. A prototype clones an object so you can start a new one. A memento captures state so you can restore the same object later. The notebook does not become a second notebook. The words on this notebook go back.
A small example#
function createDoc() { let body = ''; const snaps = new WeakMap(); return { edit(next) { body = next; }, read() { return body; }, save(label) { const token = { label }; snaps.set(token, body); return token; }, restore(token) { if (!snaps.has(token)) throw new Error('not a snapshot'); body = snaps.get(token); }, };} const doc = createDoc();doc.edit('v1');const saved = doc.save('v1');doc.edit('v2');doc.restore(saved);saved is the sealed photo. The only field on it is the label. The body lives in the WeakMap, where the caretaker cannot read it. After the second edit the notebook says "v2". After restore, it says "v1" again, on the same document.
What the code below is doing#
The sheet shows the same idea twice, then a short note about copying mutable objects.
The first shape uses classes so the names are visible. DocumentMemento is the sealed photo. Its constructor stores the real state in a WeakMap that only this originator can read, freezes the memento, and leaves label as the only visible field. TextDocument is the notebook. Title, body, and tags are private fields. edit, tag, and rename change them. save builds a memento from those fields. restore reads the WeakMap, throws if the object was never saved here, and writes the three fields back onto this same document. History is the friend holding the stack. It pushes and pops mementos and will tell you the labels. It has no way to read a body.
The asserts walk an edit of one notebook. Save "initial", write "v1 draft" and tag it, save "after v1", then rename and rewrite toward a final title. Two restores walk backward: first to the v1 draft, then to the empty original, and the history length is 0. A later save of body "x" stringifies as {"label":"x"}. The page is not on the photo. Restore still brings "x" back after further edits and a new tag. Restoring a handmade { label: 'forged' } throws, because that object was never taped shut by this document.
The second shape is an array of immutable snapshots, the way a time-travel store works. dispatch pushes the current state onto past, runs the reducer, and clears future. undo and redo move one photo between those stacks and put it back on the same store. Adding 'A' then 'B' and undoing leaves ['A']. Redo puts 'B' back. Extra undos at the start do nothing. The old object is the snapshot, so nothing is deep-copied.
The last fragment is a mutable draft email. structuredClone takes the photo so a later push onto to cannot corrupt it. Copying the clone back onto the draft is the restore. Same notebook. Old words.
When you talk about it, say this: save a sealed snapshot now, and hand it back later to roll back.
Everything below is the demonstration. Same idea, written as code you can run.
'use strict';const assert = require('node:assert/strict');Classic shape (classes, so the participant names are visible)#
const mementoState = new WeakMap(); // only this module (the Originator) can read payloadsclass DocumentMemento { // Memento — opaque handle; caretakers see only `label` constructor(label, state) { this.label = label; mementoState.set(this, state); Object.freeze(this); }}class TextDocument { // Originator #title; #body; #tags; constructor(title) { this.#title = title; this.#body = ''; this.#tags = new Set(); } edit(body) { this.#body = body; return this; } tag(t) { this.#tags.add(t); return this; } rename(t) { this.#title = t; return this; } get snapshot() { return { title: this.#title, body: this.#body, tags: [...this.#tags].sort() }; } save(label = '') { return new DocumentMemento(label, { title: this.#title, body: this.#body, tags: [...this.#tags] }); } restore(memento) { const s = mementoState.get(memento); if (!s) throw new TypeError('not a memento of this originator'); this.#title = s.title; this.#body = s.body; this.#tags = new Set(s.tags); }}class History { // Caretaker — stores mementos, never looks inside #stack = []; push(m) { this.#stack.push(m); } pop() { return this.#stack.pop(); } get labels() { return this.#stack.map((m) => m.label); } get length() { return this.#stack.length; }} const doc = new TextDocument('Spec');const history = new History();history.push(doc.save('initial'));doc.edit('v1 draft').tag('draft');history.push(doc.save('after v1'));doc.edit('v2 — final').tag('reviewed').rename('Spec (final)'); doc.restore(history.pop());doc.restore(history.pop()); const m = doc.edit('x').save('x');doc.edit('y').tag('t');doc.restore(m);Verified by 8 asserts
assert.deepEqual(doc.snapshot, { title: 'Spec (final)', body: 'v2 — final', tags: ['draft', 'reviewed'] });assert.deepEqual(history.labels, ['initial', 'after v1']);assert.deepEqual(doc.snapshot, { title: 'Spec', body: 'v1 draft', tags: ['draft'] }); // previous state backassert.deepEqual(doc.snapshot, { title: 'Spec', body: '', tags: [] }); // original stateassert.equal(history.length, 0);assert.equal(JSON.stringify(m), '{"label":"x"}'); // state is NOT exposed — only the originator can read itassert.deepEqual(doc.snapshot, { title: 'Spec', body: 'x', tags: [] }); // restore after further editsassert.throws(() => doc.restore({ label: 'forged' }), /not a memento/);Idiomatic JS shape: immutable state + array of snapshots (Redux DevTools–style time travel)#
const createStore = (reducer, initial) => { const past = []; const future = []; let state = initial; return { dispatch(action) { past.push(state); state = reducer(state, action); future.length = 0; }, undo() { if (past.length) { future.push(state); state = past.pop(); } }, redo() { if (future.length) { past.push(state); state = future.pop(); } }, get state() { return state; }, get canUndo() { return past.length > 0; }, };};const reducer = (s, a) => (a.type === 'add' ? { ...s, items: [...s.items, a.sku] } : s);const store = createStore(reducer, { items: [] });store.dispatch({ type: 'add', sku: 'A' });store.dispatch({ type: 'add', sku: 'B' });store.undo();store.redo();store.undo(); store.undo(); store.undo(); // extra undo is a no-op // Mutable state? Snapshot with structuredClone so later mutation can't corrupt the memento.const draft = { to: ['a@x.io'], body: 'hi' };const saved = structuredClone(draft);draft.to.push('b@x.io'); draft.body = 'hello';Object.assign(draft, structuredClone(saved));Verified by 7 asserts
assert.deepEqual(store.state.items, ['A', 'B']);assert.deepEqual(store.state.items, ['A']); // snapshots ARE the old immutable objects — zero copyingassert.deepEqual(store.state.items, ['A', 'B']);assert.deepEqual(store.state.items, []);assert.equal(store.canUndo, false);assert.deepEqual(saved, { to: ['a@x.io'], body: 'hi' }); // memento unaffected by the mutationassert.deepEqual(draft, { to: ['a@x.io'], body: 'hi' });See also