Part 5 of 7|April 2026|5 min
76 Lines of Undo
ArchitectureState
Command Log
Empty — click "Next Command" to start
The same pattern that powers OpenFrame. Commands append to an immutable log. State is derived. Undo flips a flag and replays.
Three things broke at once.
Undo batches crossed agent boundaries. Browser and agent diverged. Multi-tab corrupted the log. We needed a real architecture.
The 76 lines
interface ThreadState<C> {
threadId: string;
commands: C[];
undone: boolean[];
batchIds: string[];
}
class InMemoryCommandStore<C, S> {
private states = new Map<string, ThreadState<C>>();
async apply(threadId: string, cmd: C, batchId: string) {
let state = this.states.get(threadId) ?? {
threadId,
commands: [],
undone: [],
batchIds: [],
};
state.commands.push(cmd);
state.undone.push(false);
state.batchIds.push(batchId);
this.states.set(threadId, state);
}
async undo(threadId: string) {
const state = this.states.get(threadId);
if (!state) return;
const lastBatchId = state.batchIds[state.commands.length - 1];
for (let i = 0; i < state.commands.length; i++) {
if (state.batchIds[i] === lastBatchId) {
state.undone[i] = true;
}
}
}
async redo(threadId: string) {
const state = this.states.get(threadId);
if (!state) return;
const lastBatchId = state.batchIds.findLast(
(id, i) => state.undone[i]
);
if (lastBatchId) {
for (let i = 0; i < state.commands.length; i++) {
if (state.batchIds[i] === lastBatchId) {
state.undone[i] = false;
}
}
}
}
async snapshot(threadId: string, def: DomainDef<C, S>) {
const state = this.states.get(threadId);
if (!state) return { state: def.empty, canUndo: false, canRedo: false };
const active = state.commands.filter((_, i) => !state.undone[i]);
let derived = def.empty;
for (const cmd of active) {
derived = def.apply(derived, cmd);
}
return { state: derived, canUndo: true, canRedo: true };
}
}Demo
Next Command / Undo / Redo / Reset. Watch the log.
Undo is just a stack pop.
`undoStack.pop()` → `redoStack.push()` → restore state. No SQL, no event replay, no conflict resolution. Just flip a boolean and recompute.
The reducer is pure.
190 lines, no I/O. Same reducer runs in browser and on server. Test it once, deploy everywhere.
Why not CRDTs?
If you have a central server and always-online clients, you don't need CRDTs. You need a stack.
Closing
Command store + reducer + HTTP adapter + React hook: ~500 lines total. Solved undo, redo, agent sync, and multi-tab.