Command Pattern
Encapsulate a request as an object (or function pair), allowing you to parameterize, queue, log, and undo operations.
The Problemβ
You're building a text editor. Users expect Ctrl+Z to undo. But your operations are scattered:
function insertText(doc: Doc, text: string, pos: number) {
doc.content = doc.content.slice(0, pos) + text + doc.content.slice(pos);
// How do we undo this?
}
function deleteText(doc: Doc, start: number, end: number) {
doc.content = doc.content.slice(0, start) + doc.content.slice(end);
// What was deleted? Can't undo without knowing.
}
Operations don't know how to reverse themselves. Undo is impossible.
The Solutionβ
Each operation is a pair: execute and undo. Stack them for history:
import { undoable, createCommandStack } from "@pithos/core/eidos/command/command";
const doc = { content: "Hello" };
const stack = createCommandStack();
// Insert "World" at position 5
const pos = 5;
const text = " World";
const insertCmd = undoable(
() => { doc.content = doc.content.slice(0, pos) + text + doc.content.slice(pos); },
() => { doc.content = doc.content.slice(0, pos) + doc.content.slice(pos + text.length); }
);
stack.execute(insertCmd); // doc.content = "Hello World"
stack.undo(); // doc.content = "Hello"
stack.redo(); // doc.content = "Hello World"
Every operation knows how to reverse itself. Full undo/redo for free.
Reactive variantβ
The imperative version above works great with Vue, Angular, and Svelte where mutation triggers reactivity. For React and other immutable-state systems, use the reactive variant where commands are pure (state) => state transforms:
import { undoableState, createReactiveCommandStack } from "@pithos/core/eidos/command/command";
interface Doc { content: string }
const stack = createReactiveCommandStack<Doc>({
initial: { content: "Hello" },
onChange: setDoc, // React setState, Vue ref, Angular signal...
});
const pos = 5;
const text = " World";
stack.execute(undoableState(
(doc) => ({ ...doc, content: doc.content.slice(0, pos) + text + doc.content.slice(pos) }),
(doc) => ({ ...doc, content: doc.content.slice(0, pos) + doc.content.slice(pos + text.length) }),
));
// onChange fires with { content: "Hello World" }
stack.undo(); // onChange fires with { content: "Hello" }
stack.redo(); // onChange fires with { content: "Hello World" }
Same pattern, no mutation. The stack manages state and notifies your framework.
Command vs Mementoβ
Both patterns enable undo/redo, but they work differently:
- Command tracks individual operations and their reversal. Use when operations are known and reversible.
- Memento captures entire state snapshots. Use when state is complex or operations aren't easily reversible.
Live Demoβ
- command.ts
- Usage
/**
* Kanban command stack using Pithos's reactive command pattern.
*
* undoableState() creates pure (State) => State transforms.
* createReactiveCommandStack manages state + undo/redo + onChange notifications.
*/
import { undoableState, createReactiveCommandStack } from "@pithos/core/eidos/command/command";
import { COLUMNS, INITIAL_BOARD } from "@/data/kanban";
import type { BoardState, ColumnId, HistoryEntry } from "./types";
function moveTask(board: BoardState, taskId: string, from: ColumnId, to: ColumnId): BoardState {
const task = board[from].find((t) => t.id === taskId);
if (!task) return board;
return { ...board, [from]: board[from].filter((t) => t.id !== taskId), [to]: [...board[to], task] };
}
export function createKanbanStack(onChange: (board: BoardState) => void) {
const stack = createReactiveCommandStack<BoardState>({ initial: INITIAL_BOARD, onChange });
const log: HistoryEntry[] = [];
let cursor = -1;
let entryId = 0;
return {
move(taskId: string, from: ColumnId, to: ColumnId) {
const task = stack.state[from].find((t) => t.id === taskId);
if (!task) return;
stack.execute(undoableState(
(b) => moveTask(b, taskId, from, to),
(b) => moveTask(b, taskId, to, from),
));
log.length = cursor + 1;
entryId++;
log.push({ id: entryId, description: `Move "${task.title}" from ${COLUMNS[from].label} to ${COLUMNS[to].label}` });
cursor = log.length - 1;
},
undo(): boolean { const ok = stack.undo(); if (ok) cursor--; return ok; },
redo(): boolean { const ok = stack.redo(); if (ok) cursor++; return ok; },
clear() { stack.clear(); log.length = 0; cursor = -1; entryId = 0; },
get state(): BoardState { return stack.state; },
get canUndo(): boolean { return stack.canUndo; },
get canRedo(): boolean { return stack.canRedo; },
get history(): readonly HistoryEntry[] { return log; },
get cursor(): number { return cursor; },
};
}
import { useState, useCallback, useRef } from "react";
import { createKanbanStack } from "@/lib/command";
import { COLUMNS, INITIAL_BOARD } from "@/data/kanban";
import type { BoardState, ColumnId, Task } from "@/lib/types";
export function useKanbanBoard() {
const [board, setBoard] = useState<BoardState>(INITIAL_BOARD);
const [isReplaying, setIsReplaying] = useState(false);
const [highlightedTask, setHighlightedTask] = useState<string | null>(null);
const stackRef = useRef(createKanbanStack(setBoard));
const [, forceUpdate] = useState(0);
const sync = useCallback(() => forceUpdate((n) => n + 1), []);
const handleDrop = useCallback((taskId: string, from: ColumnId, to: ColumnId) => {
if (from === to || isReplaying) return;
stackRef.current.move(taskId, from, to);
sync();
}, [isReplaying, sync]);
const handleUndo = useCallback(() => { if (!isReplaying) { stackRef.current.undo(); sync(); } }, [isReplaying, sync]);
const handleRedo = useCallback(() => { if (!isReplaying) { stackRef.current.redo(); sync(); } }, [isReplaying, sync]);
const handleReset = useCallback(() => { stackRef.current.clear(); sync(); }, [sync]);
const handleReplay = useCallback(async () => {
const stack = stackRef.current;
const totalCommands = stack.cursor + 1;
if (totalCommands <= 0 || isReplaying) return;
setIsReplaying(true);
while (stack.canUndo) stack.undo();
sync();
for (let i = 0; i < totalCommands; i++) {
await new Promise((r) => setTimeout(r, 600));
const entry = stack.history[stack.cursor + 1];
if (entry) {
const match = entry.description.match(/Move "(.+)" from (.+) to (.+)/);
if (match) {
const [, title, fromLabel] = match;
const from = (Object.entries(COLUMNS).find(([, v]) => v.label === fromLabel)?.[0] ?? "todo") as ColumnId;
const task = stack.state[from].find((t: Task) => t.title === title);
if (task) { setHighlightedTask(task.id); await new Promise((r) => setTimeout(r, 400)); }
}
}
stack.redo();
sync();
setHighlightedTask(null);
}
setIsReplaying(false);
}, [isReplaying, sync]);
const { canUndo, canRedo, history, cursor } = stackRef.current;
return {
board, isReplaying, highlightedTask, canUndo, canRedo, history, cursor,
handleDrop, handleUndo, handleRedo, handleReset, handleReplay,
};
}
Real-World Analogyβ
A macro recorder in a spreadsheet. You record a sequence of actions (commands), replay them on different data (queue), undo mistakes (undo), and save the macro for later (serialize). Each action is a discrete, reversible unit.
When to Use Itβ
- Implement undo/redo functionality
- Queue operations for later execution
- Log all operations for audit trails
- Support transactional behavior (rollback on failure)
When NOT to Use Itβ
If your operations aren't reversible or you just need to snapshot state, consider Memento instead.
APIβ
- undoable β Create an imperative command with execute and undo thunks
- createCommandStack β Imperative command history with undo/redo
- undoableState β Create a pure state command
(S) => S - createReactiveCommandStack β Reactive command history with managed state and
onChangecallback - safeExecute β (deprecated) Use
Result.fromThrowablefrom Zygos instead