StackPractices
intermediate Por Mathias Paulenko

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

Temas: design

Visión General

El patrón Command convierte una petición en un objeto independiente que lleva todo lo necesario para ejecutarla. Ese desacople te permite parametrizar llamadas, encolar o registrar operaciones y, el punto de esta página, implementar undo/redo dándole a cada comando una operación inversa. Es la columna vertebral de editores, herramientas de dibujo, constructores de formularios y cualquier lugar donde el usuario espera que Ctrl+Z simplemente funcione.

Undo tiene dos implementaciones clásicas. Esta página usa comandos inversos: cada Command sabe cómo deshacerse a sí mismo (insert se deshace borrando el mismo rango). La alternativa, Memento, guarda un snapshot del estado del receiver y lo restaura. Los comandos inversos cuestan más de escribir pero son compactos; los snapshots son triviales pero crecen con el tamaño del documento. Yo uso comandos inversos cuando la profundidad del historial o el tamaño del documento hacen que los snapshots salgan caros.

flowchart diagram: UI[Acción de UI<br/>Ctrl+Z / Ctrl+Shift+Z

Cuándo Usar

  • Aplicaciones interactivas que necesitan undo/redo: editores, canvas, diseñadores.
  • Operaciones que querés encolar, registrar, programar o ejecutar remoto; un objeto comando serializa mejor que una llamada a método.
  • Flujos colaborativos o transaccionales donde cada paso puede necesitar rollback independiente.

Cuándo NO usarlo

  • CRUD simple sin necesidad de historial: una llamada a método directa es más clara.
  • Mutaciones de alta frecuencia donde guardar un comando por tecla es demasiado pesado; agrupá en comandos compuestos o snapshotteá periódicamente.
  • Operaciones para las que no podés escribir una inversa confiable. Un comando “enviar email” no puede des-enviar; modelarlo como undoable le da al usuario una expectativa falsa.

Problema

Un editor de texto llama métodos del objeto documento directamente. Agregar undo implica exponer estado interno o hackear un snapshot en cada handler; agregar macros implica duplicar la orquestación en la capa de UI. La petición (“insertá 5 caracteres en la posición 3”) y la ejecución quedan soldadas, así que no hay nada que guardar, replicar o revertir.

Solución

La solución son tres roles: una interfaz Command con execute y undo, comandos concretos que recuerdan lo que necesitan para revertir, y un invoker que maneja dos pilas. El receiver queda tonto: solo sabe mutar.

// El contrato: cada comando sabe hacerse y deshacerse
interface Command {
  execute(): void;
  undo(): void;
  getName(): string;
}

// Receiver — sin historial ni lógica de undo, solo mutaciones
class TextDocument {
  private content = '';

  insert(text: string, position: number): void {
    this.content =
      this.content.slice(0, position) + text + this.content.slice(position);
  }

  delete(position: number, length: number): string {
    const removed = this.content.slice(position, position + length);
    this.content =
      this.content.slice(0, position) +
      this.content.slice(position + length);
    return removed;
  }

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

// Los comandos concretos guardan justo lo que undo() necesita
class InsertCommand implements Command {
  constructor(
    private document: TextDocument,
    private text: string,
    private position: number,
  ) {}

  execute(): void {
    this.document.insert(this.text, this.position);
  }

  undo(): void {
    this.document.delete(this.position, this.text.length);
  }

  getName(): string {
    return `Insert "${this.text}"`;
  }
}

class DeleteCommand implements Command {
  private deletedText = '';

  constructor(
    private document: TextDocument,
    private position: number,
    private length: number,
  ) {}

  execute(): void {
    this.deletedText = this.document.delete(this.position, this.length);
  }

  undo(): void {
    this.document.insert(this.deletedText, this.position);
  }

  getName(): string {
    return `Delete ${this.length} chars`;
  }
}

// Invoker — dos pilas, y la regla que hace honesto al undo
class CommandHistory {
  private undoStack: Command[] = [];
  private redoStack: Command[] = [];

  execute(command: Command): void {
    command.execute();
    this.undoStack.push(command);
    // Una acción nueva invalida la pila de redo — siempre
    this.redoStack = [];
  }

  undo(): void {
    const command = this.undoStack.pop();
    if (!command) return;
    command.undo();
    this.redoStack.push(command);
  }

  redo(): void {
    const command = this.redoStack.pop();
    if (!command) return;
    command.execute();
    this.undoStack.push(command);
  }

  canUndo(): boolean {
    return this.undoStack.length > 0;
  }

  canRedo(): boolean {
    return this.redoStack.length > 0;
  }
}

// Uso
const doc = new TextDocument();
const history = new CommandHistory();

history.execute(new InsertCommand(doc, 'Hello', 0));
history.execute(new InsertCommand(doc, ' World', 5));
console.log(doc.getContent()); // "Hello World"

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

history.redo();
console.log(doc.getContent()); // "Hello World"

Notá que el invoker nunca mira el interior de un comando: solo los mueve entre pilas. Eso mantiene a CommandHistory genérico — funciona con cualquier receiver, que es por qué los editores reales lo exponen como componente reutilizable.

Explicación

Lo que hace funcionar al undo es la asimetría: execute produce efectos, undo produce el efecto inverso, y el objeto comando es el único lugar que conoce ambos. InsertCommand no snapshottea el documento; solo sabe que insertar text en position se deshace borrando text.length caracteres en la misma posición. DeleteCommand sí necesita memoria (el string borrado), porque el borrado no es invertible sin él. Ese es el contrato real del patrón: cada comando guarda el estado mínimo que su inversa requiere.

Dos detalles deciden si el historial se comporta bien:

  • Limpiá el redo en cada execute nuevo. Si el usuario deshace dos veces y después tipea algo nuevo, el “futuro” que describían esas entradas de redo ya no existe. Olvidarse del redoStack = [] es el bug de undo más común: los usuarios re-hacen hacia una línea temporal vieja y corrompen el documento.
  • undo() saca de la pila y empuja a redo. Las pilas mueven comandos entre sí; nada se copia ni se recrea, así que el estado capturado por la instancia (como deletedText) sobrevive el viaje de ida y vuelta.

Sobre memoria: los comandos inversos son chicos, pero un historial sin tope en un editor de sesiones largas igual pierde. Ponéle un tope a undoStack (if (this.undoStack.length > 100) this.undoStack.shift()) o colapsá los comandos viejos en un snapshot: un híbrido pragmático donde los últimos N comandos son reversibles y todo lo anterior es un checkpoint de Memento.

Dónde compone esto: los comandos son unidades naturales para un log de operaciones (serializá getName() + args a JSON para recuperación de crash), para encolar trabajo en otro worker, y para flujos de UI optimista donde undo se vuelve la compensación de una escritura al servidor que falló.

Cómo se compara con enfoques vecinos

EnfoqueQué guardaCosto de memoriaFidelidad del undoMejor para
Command (esta página)Operación + datos inversosBajoExactaUndo/redo interactivo, logs de operaciones
MementoSnapshots de estado completoAltoExactaEstado chico, receivers simples
Event sourcingEventos ordenados append-onlyMedio, crece siempreReconstrucción por replayAudit logs, sistemas colaborativos

Vale mencionar event sourcing porque resuelve un problema cercano: guardar cada cambio como un evento y reconstruir el estado repitiéndolos. Da garantías más fuertes que una pila de undo (el log es la fuente de verdad, no una comodidad de UI), a costa de un modelo mucho más grande. Para un editor de texto, los comandos son el tamaño correcto.

Un detalle sobre determinismo: undo() tiene que restaurar estado desde lo que el comando capturó al construirse, nunca preguntándole al receiver “cómo te ves ahora”. Si el usuario tipea entre execute y undo, una posición computada perezosamente queda mal; una posición guardada de entrada sigue siendo correcta. Tratá las instancias de comando como registros inmutables una vez creadas.

Variantes

MacroCommand — agrupar operaciones en una sola unidad undoable

class MacroCommand implements Command {
  constructor(private commands: Command[]) {}

  execute(): void {
    for (const command of this.commands) command.execute();
  }

  // Deshacer en orden INVERSO: el último cambio aplicado se rebobina primero
  undo(): void {
    for (const command of [...this.commands].reverse()) command.undo();
  }

  getName(): string {
    return `Macro of ${this.commands.length} commands`;
  }
}

Así es como “pegar 500 filas” o “formatear documento” se convierten en un solo Ctrl+Z en vez de veinte.

AsyncCommand — undo para operaciones que devuelven Promises

interface AsyncCommand {
  execute(): Promise<void>;
  undo(): Promise<void>;
}

class CommandQueue {
  private undoStack: AsyncCommand[] = [];

  // La ejecución secuencial mantiene el orden cuerdo para trabajo async undoable
  async execute(command: AsyncCommand): Promise<void> {
    await command.execute();
    this.undoStack.push(command);
  }

  async undo(): Promise<void> {
    const command = this.undoStack.pop();
    if (command) await command.undo();
  }
}

Los comandos async necesitan una cola, no solo una pila: dos execute superpuestos reordenan el historial. Serializalos.

Historial acotado — limitar memoria en sesiones largas

const MAX_HISTORY = 100;

execute(command: Command): void {
  command.execute();
  this.undoStack.push(command);
  if (this.undoStack.length > MAX_HISTORY) {
    this.undoStack.shift(); // el comando más viejo cae; ya no se puede deshacer
  }
  this.redoStack = [];
}

Mejores Prácticas

  • Guardá el estado mínimo que undo() necesita (el texto borrado, el valor anterior), no un snapshot del documento.
  • Limpiá la pila de redo en cada execute nuevo; los redos viejos corrompen documentos.
  • Acotá el historial y colapsá o snapshotteá más allá del tope.
  • Mantené los comandos deterministas: si execute no puede reproducir el mismo efecto en redo, undo/redo deriva.
  • Nombrá los comandos para usuarios ("Insert text", no InsertCommand), porque los paneles de historial muestran getName().
  • Registrá los comandos ejecutados; el objeto historial es un audit trail gratis.

Errores Comunes

  • Snapshottear el documento entero por comando: la memoria crece con tamaño del documento × profundidad del historial. Invertí la operación; el snapshot solo como checkpoint periódico.
  • No limpiar la pila de redo: después de undo → undo → acción nueva, redo tiene que quedar vacía. Todo editor maduro sigue esta regla.
  • Undo que depende del estado actual: undo tiene que restaurar con datos guardados, no consultando el documento “dónde está ahora”; las posiciones pudieron moverse.
  • Modelar acciones irreversibles como undoables: llamadas de red, emails, pagos. O las excluís o modelás el undo como una acción compensatoria que el usuario pueda ver.
  • Comandos que capturan referencias compartidas: si un comando retiene un objeto mutable que después cambia, su undo replica estado mutado. Copiá al construir.
  • Convertir cada tecla en un comando: agrupá las ráfagas de tipeo en un solo comando de insert (por palabra o por pausa), o el historial se llena de ruido.

Resumen

  • Cada Command lleva execute() más el estado mínimo que undo() necesita para invertirlo.
  • El invoker maneja las pilas undo/redo y limpia redo en cada acción nueva.
  • Las operaciones inversas escalan; los snapshots completos no. Combinálo con Memento para checkpoints.
  • Las variantes Macro y async cubren las dos formas que necesitan los editores reales.
  • Acotá el historial; una pila de undo sin tope es un memory leak con UI.

See Also

Preguntas frecuentes

¿Cuál es la diferencia entre undo con Command y con Memento?

Command guarda la operación más los datos para revertirla (chico, quirúrgico, más trabajo por comando). Memento guarda el estado completo del receiver (simple, más pesado en memoria). Los editores suelen combinarlos: comandos para el historial reciente, snapshots como checkpoints.

¿Puedo usar comandos para logging de API o colas?

Sí. Un objeto comando es solo data que describe una operación, así que serializa naturalmente. Envolver llamadas HTTP como comandos te da replay, colas de retry y un log de operaciones gratis.

¿Por qué se limpia la pila de redo con comandos nuevos?

Porque después de un undo, las entradas de redo describen un futuro que una acción nueva invalida. Los editores tratan la acción nueva como una rama nueva; conservar redos viejos dejaría al usuario "re-hacer" hacia un estado del documento que nunca coexistió con la edición nueva.

¿Cómo evito que el historial se coma la memoria?

Acotá la pila (descartá el comando más viejo), agrupá el input ruidoso (ráfagas de tipeo) en comandos únicos, y snapshotteá periódicamente para poder descartar comandos viejos sin perder el undo por completo.

¿Este patrón es overkill para un proyecto chico?

Normalmente sí. Si no hay requisito de undo, cola o replay, una interfaz más un invoker agregan indirección por nada. Empezá con llamadas a método directas; introducí comandos cuando de verdad necesites el historial.

¿Se pueden compartir comandos entre cliente y servidor?

Se puede. Así funcionan los editores colaborativos y los sistemas de command-sourcing. Serializá el comando (nombre + args) por la red; el receptor lo re-instancia. La salvedad: las inversas se computan contra el estado del servidor, no del cliente.