Patrón Memento para Snapshots y Restauración de Estado
Captura y externaliza el estado interno de un objeto sin violar la encapsulación, habilitando undo, serialización y rollback de estado en aplicaciones
El patrón Memento captura y externaliza el estado interno de un objeto para restaurarlo después, sin exponer ese estado al exterior. Mientras Command registra las operaciones necesarias para deshacer un cambio, Memento simplemente recuerda cómo era el objeto — por eso encaja de forma natural en pilas de undo de editores, checkpoints de juegos y borradores de formularios multi-paso.
Cuándo Usarlo
- Necesitas restaurar el estado completo de un objeto, no solo revertir la última operación.
- Los internos del objeto son complejos y deben seguir privados — el undo no puede meterse dentro.
- Los snapshots tienen que sobrevivir serialización: guardados a disco, enviados por red o guardados por sesión.
- Un wizard o editor necesita “restaurar todo al paso N”, no “deshacer la acción N”.
Evítalo cuando el estado es pequeño y las operaciones son baratas de invertir — para “borrar la última palabra”, una pila de Command cuesta menos memoria que copiar el documento entero en cada tecla.
Problema
Una app de dibujo necesita undo, pero su documento guarda las formas como objetos anidados de coordenadas y estilos. Exponer getters para cada campo rompe la encapsulación, y grabar operaciones tampoco sirve: herramientas externas pueden mutar las formas directamente, así que no hay una “operación inversa” fiable que replayar hacia atrás.
Solución
// 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];
}
// Crear snapshot
save(): EditorMemento {
return new EditorSnapshot(
this.content,
this.cursorPosition,
this.selectionRange
);
}
// Restaurar desde 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;
}
}
// Implementación del memento (opaca para clientes)
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; }
}
// El Caretaker gestiona el historial
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]);
}
}
// Uso
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"
El Caretaker guarda los mementos pero los trata como handles opacos — solo el Originator que creó un snapshot puede leerlo de vuelta.
Código companion: el repo companion de memento-pattern-state tiene los dos ejemplos de esta página ejecutables con Node ≥ 22.18, más tests que cubren undo, aislamiento del checkpoint y restore del wizard.
Variación: Sistema de Checkpoints de Juego
// 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]; }
}
Fíjate en los detalles: createCheckpoint hace spread del array de inventario ([...this.inventory]) para que el snapshot no lo comparta con el estado vivo, y getInventory devuelve una copia para que nadie pueda mutar el checkpoint después de cargarlo. Si loadCheckpoint recibiera el array original, el siguiente inventory.push() reescribiría el checkpoint guardado sin que nadie lo notara.
Cómo Funciona
- Originator — el objeto cuyo estado importa. Solo él puede crear y leer mementos.
- Memento — el snapshot. Para el Caretaker es un token opaco; por dentro lleva lo que el Originator necesite.
- Caretaker — dueño de la pila de historial. Decide cuándo hacer snapshot (tecla, cambio de paso, timer), nunca qué.
- Client — dispara
backup()yundo()/redo()a través del Caretaker.
Es lo que el libro GoF llama la separación narrow/wide interface: el Caretaker ve una interfaz estrecha (guarda esto, devuélvelo), mientras el Originator ve la ancha (acceso completo a campos). En TypeScript sale gratis — el Caretaker solo conoce la interfaz EditorMemento, y el cast dentro de restore queda privado del Originator.
Consecuencias y Trade-offs
Memento cambia memoria por simplicidad. Cada snapshot es una deep copy, así que el coste crece con profundidad del historial × tamaño del estado — bien para un formulario, doloroso para un documento de 2 MB con snapshot por tecla.
| Enfoque | Guarda | Memoria | Mejor cuando |
|---|---|---|---|
| Memento | Snapshots de estado completo | Alta (deep copies) | El estado es difícil de recomputar; necesitas “restaurar al paso N” |
| Command | Operaciones + inversas | Baja | Las acciones son baratas de revertir; importa la granularidad del undo |
| Prototype | Grafo de objetos clonado | Media | Quieres una copia desde la que seguir, no un historial |
Hay una cuarta opción cuando el estado ya es inmutable (estilo Redux): guardar referencias a árboles de estado viejos en vez de copiar. Es un historial de mementos que casi no cuesta, porque las ramas sin cambios se comparten entre versiones.
Consideraciones de Producción
- Limita el historial. El ejemplo de formulario guarda 50 snapshots; un editor con un documento grande hace la misma cuenta y elige un límite que entre en su presupuesto de memoria.
- Comparte estructura.
structuredClonecopia todo cada vez; las estructuras persistentes (Immutable.js,producede Immer con estados pasados congelados) convierten un “snapshot” en un puntero a una raíz vieja. - Versiona los mementos. Los snapshots persistidos entre despliegues sobrevivirán a tu esquema — un campo renombrado deja los saves viejos sin restaurar. Incluye una clave
versiony migra al cargar. - No snapshots por tecla. Un
JSON.stringifyde un estado grande por tecla es una tormenta de GC. Haz snapshot al guardar, en pausa o en transiciones significativas — o guarda deltas entre snapshots en vez de estados completos.
Errores Comunes
- Mementos mutables. Devolver el objeto
fieldsvivo en vez de una copia permite que ediciones posteriores muten snapshots “viejos” — el historial se corrompe sin ruido. - Copias superficiales. El mismo bug, más sutil: clonas el nivel superior pero los arrays y objetos anidados siguen compartidos.
- El Caretaker leyendo mementos. En cuanto el Caretaker inspecciona el contenido, la lógica de undo se fuga del Originator y cada cambio de estado rompe dos sitios.
- Serializar instancias de clase. Los snapshots guardados como referencias a objetos vivos (o
toJSONatado a la forma actual de las clases) fallan en cuanto cambia el esquema. Serializa datos planos con campoversion.
Solución de Problemas
- El undo restaura el estado pero la UI no se actualiza: el restore funcionó — la vista no fue notificada. Emite un evento de cambio desde el Originator o devuelve el estado restaurado para que la capa de render reaccione.
- El historial crece hasta congelar la pestaña: una pila de snapshots sin límite es la sospechosa habitual. Limita
history.length, o cambia a delta encoding para que cada entrada guarde solo lo que cambió. - El objeto restaurado sigue cambiando solo: mantenía referencias a estado mutable compartido. Haz deep copy al guardar (
structuredClone, oJSON.parse(JSON.stringify())para datos JSON-safe) para que el snapshot quede congelado en el momento de captura. - Los borradores viejos no cargan tras un refactor: los mementos serializados antes de un cambio de esquema no encajan con los campos nuevos. Añade
versional snapshot y escribe una migración por cada cambio de formato.
Puntos Clave
- Memento guarda cómo era el objeto; Command guarda qué pasó. Elige por modelo de coste — deep copies vs. operaciones reversibles — no por costumbre.
- Solo el Originator lee mementos; el Caretaker gestiona una pila y nada más.
- Lo caro es la deep copy, así que planifica límites de historial, compartición estructural o deltas antes de que duela.
Temas Avanzados
Escenario: Memento para Editor de Formularios
// Patrón Memento: guardar y restaurar estado sin exponer internos
type FormState = {
fields: Record<string, string>;
errors: Record<string, string>;
step: number;
}
class FormMemento {
constructor(private state: FormState) {}
getState(): FormState { return { ...this.state }; }
}
// Originator: el formulario
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: historial de estados
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; }
}
// Uso: wizard multi-paso
const form = new FormEditor();
const history = new FormHistory();
// Paso 1
form.setField("email", "user@test.com");
form.setStep(1);
history.push(form.save());
// Paso 2
form.setField("name", "Alice");
form.setField("phone", "555-1234");
form.setStep(2);
history.push(form.save());
// Paso 3
form.setField("address", "123 Main St");
form.setStep(3);
// El usuario pulsa "Atrás"
const prev = history.pop();
if (prev) form.restore(prev);
console.log(form.getState().step); // 2
console.log(form.getState().fields.name); // "Alice"
Lecciones:
FormMementocopiafieldsyerrorsensave()— un spread superficial de nivel superior dejaría los mapas anidados compartidos.maxHistory = 50acota la memoria; el wizard no necesita undo infinito.- El Caretaker nunca abre el memento — hace push y pop de objetos opacos.
- Restaurar es un
pop(), no un cursor deundo()— un wizard que vuelve atrás invalida el camino hacia adelante, igual quehistory.slice(0, currentIndex + 1)en el ejemplo del editor.
Memento vs Command para undo: ¿cuál uso?
Usa Memento cuando necesitas restaurar el estado completo — formularios, editores visuales, checkpoints de juego — sobre todo cuando el cambio no se puede describir como operación. Usa Command cuando deshaces operaciones individuales y cada una tiene una inversa barata. Memento cuesta más memoria pero menos bookkeeping: guarda el estado entero y no le importa cómo llegaste ahí. Para wizards y formularios multi-paso, Memento encaja limpio. Para undo a nivel de texto donde cada acción tiene una inversa obvia, Command es más ligero.
Errores Comunes en Producción
- Deep copy del documento entero por tecla. La demo funciona; el archivo de 500 KB de producción no. Mide primero, luego regula los snapshots o comparte estructura entre versiones.
- Snapshots que solo tu código actual sabe leer. Serializa datos planos, no instancias — un snapshot que necesita las clases de hoy se vuelve ilegible tras el primer renombrado.
- Exponer internos del memento “solo esta vez”. Un
history[3].getState().fields = ...de un compañero con prisa y pasado y presente colapsan en el mismo objeto. - Meter en el memento lo que no es estado. Posición de scroll, requests de red pendientes y timers no son estado del documento — restaurarlos repite requests viejas o salta el viewport. Snapshot del estado, lo demás lo gestiona el editor.
- Sin campo
versionen snapshots persistidos. El primer cambio de esquema convierte cada borrador guardado en un error de parseo o, peor, en una restauración parcial silenciosa.
Preguntas frecuentes
¿En qué se diferencia de Command?
Command guarda operaciones y las revierte una a una. Memento guarda snapshots completos del estado. Los mementos son más grandes pero más simples de implementar para objetos complejos — y siguen funcionando cuando el cambio no se puede expresar como operación, como una herramienta externa mutando tu documento.
¿Puedo usarlo con Redux?
El time-travel de Redux DevTools es un historial de mementos sobre estado inmutable — por eso puede saltar entre snapshots casi gratis. Con estado inmutable guardas punteros, no copias.
¿Cuánta memoria usa un historial de mementos?
Aproximadamente profundidad del historial × tamaño del estado, porque cada entrada es una copia completa. Un formulario de 200 KB con 50 snapshots son ~10 MB; la compartición estructural o los deltas lo reducen a lo que realmente cambió.
¿Debería serializar los mementos a disco?
Sí — así es como "restaurar borrador" sobrevive a un reinicio del navegador. Serializa datos planos con campo version, no instancias de clase, y escribe migraciones cuando cambie el esquema.
¿Cuándo conviene el patrón State en su lugar?
State modela transiciones entre estados con nombre que llevan su propio comportamiento (reproduciendo → pausado → parado). Memento solo archiva valores para rollback. Si cada estado necesita comportamiento distinto, ese es el trabajo de State — aunque puedes seguir haciendo snapshot del estado activo.
Recursos Relacionados
Command Pattern con Undo/Redo en TypeScript
Implementá el patrón Command para encapsular peticiones como objetos, habilitando operaciones undo/redo, encolado de peticiones y registro de operaciones
PatternPrototype Pattern para Clonacion de Objetos y Plantillas
Crea nuevos objetos copiando existentes, permitiendo plantillas pre-configuradas y evitando explosion de subclases cuando la creacion de objetos es costosa
PatternInterpreter Pattern para Lenguajes de Expresion
Construye un interprete de lenguaje que evalua expresiones y reglas representando la gramatica como objetos componibles, util para formulas, queries y reglas de negocio
PatternPatrón Iterator para Colecciones Personalizadas
Accede a los elementos de un objeto agregado secuencialmente sin exponer su representación interna — árboles, grafos y streams asíncronos — con el patrón Iterator.
PatternPatrón Mediator para Componentes UI Desacoplados
Reduce dependencias caóticas entre componentes UI introduciendo un mediador que centraliza la comunicación y evita referencias explícitas entre pares