Pattern Command
Encapsulez une requête sous forme d'objet (ou paire de fonctions), permettant de paramétrer, mettre en file, journaliser et annuler des opérations.
Le Problème
Vous développez un éditeur de texte. Les utilisateurs s'attendent à ce que Ctrl+Z annule. Mais vos opérations sont dispersées :
function insertText(doc: Doc, text: string, pos: number) {
doc.content = doc.content.slice(0, pos) + text + doc.content.slice(pos);
// Comment annuler ça ?
}
function deleteText(doc: Doc, start: number, end: number) {
doc.content = doc.content.slice(0, start) + doc.content.slice(end);
// Qu'est-ce qui a été supprimé ? Impossible d'annuler sans le savoir.
}
Les opérations ne savent pas comment s'inverser. L'undo est impossible.
La Solution
Chaque opération est une paire : exécuter et annuler. Empilez-les pour l'historique :
import { undoable, createCommandStack } from "@pithos/core/eidos/command/command";
const doc = { content: "Hello" };
const stack = createCommandStack();
// Insérer "World" à la 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"
Chaque opération sait comment s'inverser. Undo/redo complet gratuitement.
Variante réactive
La version impérative ci-dessus fonctionne très bien avec Vue, Angular et Svelte où la mutation déclenche la réactivité. Pour React et les autres systèmes à état immuable, utilisez la variante réactive où les commandes sont des transformations pures (state) => state :
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 se déclenche avec { content: "Hello World" }
stack.undo(); // onChange se déclenche avec { content: "Hello" }
stack.redo(); // onChange se déclenche avec { content: "Hello World" }
Même pattern, pas de mutation. Le stack gère l'état et notifie votre framework.
Command vs Memento
Les deux patterns permettent l'undo/redo, mais fonctionnent différemment :
- Command suit les opérations individuelles et leur inversion. À utiliser quand les opérations sont connues et réversibles.
- Memento capture des snapshots complets de l'état. À utiliser quand l'état est complexe ou que les opérations ne sont pas facilement réversibles.
Démo
- 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,
};
}
Analogie
Un enregistreur de macros dans un tableur. Vous enregistrez une séquence d'actions (commandes), les rejouez sur d'autres données (file), annulez les erreurs (undo), et sauvegardez la macro pour plus tard (sérialisation). Chaque action est une unité discrète et réversible.
Quand l'Utiliser
- Implémenter une fonctionnalité undo/redo
- Mettre des opérations en file pour exécution différée
- Journaliser toutes les opérations pour l'audit
- Supporter un comportement transactionnel (rollback en cas d'échec)
Quand NE PAS l'Utiliser
Si vos opérations ne sont pas réversibles ou que vous avez juste besoin de capturer l'état, envisagez Memento à la place.
API
- undoable — Créer une commande impérative avec des thunks execute et undo
- createCommandStack — Historique de commandes impératif avec undo/redo
- undoableState — Créer une commande d'état pure
(S) => S - createReactiveCommandStack — Historique de commandes réactif avec état géré et callback
onChange - safeExecute — (déprécié) Utilisez
Result.fromThrowablede Zygos à la place