Designing history for async React — four undo architectures for a drag-and-drop dashboard, and the transactional replay model that survives them all.
…until one click starts several state changes, waits on the network, overlaps another click, and touches data too large to snapshot repeatedly.
Then undo stops being a data structure — and becomes a product decision.
Full-state snapshots scale with state × depth. 2 MB tree × 50 = 100 MB retained.
Drag position, hover, focus in the same tree → one gesture = 200 undo steps of nothing.
Closures capture component scope. Can't persist, inspect, or replay.
Network resolves after the user undid. The write lands in a world history no longer describes.
One intent = N internal dispatches. Undo reverses a fraction of a gesture.
What does your user expect ⌘Z to reverse? That answer is a UX contract. Architecture follows it.
text editors — ProseMirror steps
forms, config, inspectors
dashboards, canvases, DnD — one committed intent
save points, version control
function commit(next) {
past.push(current); // whole tree, every time
future = [];
current = next;
}
function undo() {
future.push(current);
current = past.pop(); // restore a full copy
}You're storing full copies on purpose. 50 drag gestures on a 2 MB tree ≈ 100 MB retained, per tab. Structural sharing doesn't save you — the copies are the product.
dragPosition in the tree → every pointermove is a snapshot. Undo walks back frame-by-frame. Your users undo the 7th frame of a gesture, not the gesture.
The ecosystem knows: zundo ships partialize (choose tracked fields) and limit (cap depth) as first-class flags. Band-aids on a pattern — a hint we need a different unit.
const [next, patches, inversePatches] = produceWithPatches(
state, draft => { draft.cards[3].column = "done"; }
);
// patches = [{op:"replace", path:["cards",3,"column"], value:"done"}]
// inversePatches = [{op:"replace", path:["cards",3,"column"], value:"todo"}]
// └─ the undo op, generated, minimal, serializable
undo() → state = applyPatches(state, history.pop().inverse)
redo() → state = applyPatches(state, future.pop().patches)A patch is path + value. If anything else touched that address — late async completion, another actor — applyPatches writes into a world the patch never knew. Silent corruption, no error.
You can't say which transaction a patch belongs to. No transaction → no unit-level undo → failure 05 walks back in.
Verdict: the right storage format for deltas. The wrong history for intents.
class MoveCard {
do() { cards[this.id].column = this.to; }
undo() { cards[this.id].column = this.from; } // explicit inverse
}
history.push(new MoveCard(id, from, to));
undo() → history.pop().undo()
// the temptation:
history.push({ do: () => moveCard(id, to), undo: () => moveCard(id, from) })
// ↑ closure over component scope — failure 03Not serializable. Not inspectable. Not replayable after refresh. And they leak props that went stale three re-renders ago.
Undoing a network write isn't write.reverse() — it's a compensating action that can itself fail. Inverse correctness becomes your permanent job.
The ProseMirror fix: steps are plain data + an interpreter registry. The inverse is derived: step.invert(doc). Data survives; closures don't.
Append-only log of semantic events → state is a projection of the log.
Undo = re-project to N−1 · Redo = re-project to N+1 · one function, no inverses
type Event = { txId: string; type: "card.moved"|...; payload: Json; ts: number }
const log: Event[] = []; // append-only
let cursor = -1; // projection position
let checkpoint = { index: -1, state: init } // rolling, every K events
commit(e) { log.push(e); cursor = log.length-1;
if (log.length % K === 0) checkpoint = {index:cursor, state} }
project(to) { // undo AND redo
const from = checkpoint.index > to ? -1 : checkpoint.index
let s = from === -1 ? init : checkpoint.state
for (const e of log.slice(from+1, to+1)) s = apply(s, e) // forward only
state = s; cursor = to
}| mechanism | meaning | kills |
|---|---|---|
| Semantic events | log card.moved {id,to}, not structural diffs | 05 batching |
| txId at intent time | mint on pointerdown — before any await | 04 late async |
| Atomic publication | one gesture → one append, N mutations inside | 05 batching |
| Closure-free payloads | events are JSON — inspect, persist, replay | 03 closures |
| Rolling checkpoint | every K events: snapshot + truncate prefix | 01 memory |
Pollution (02) dies automatically: ephemeral updates are never events — they don't reach the log.
txId minted at intent time · event published · save starts
cursor rewinds · projection re-runs · UI correct
headTx === txId? — no. Snapshot/patches engines: the write mutates state history doesn't describe — ghost state. Replay: completion rejected or reopened as a new event. The log stays append-only.
Publish at intent time. The network result verifies or reopens — never splices behind the cursor.
isPending is not historyPending flags are view-layer. Put them in the tracked tree and failure 02 walks back in.
Render a thousand frames during a drag. Publish one event. startTransition marks the interactive path; history records the intent.
A completion for a tx that's no longer head becomes a fresh event the user can undo. Append-only, always.
| your unit is… | state | async inside? | choose |
|---|---|---|---|
| keystroke / field edit | small | no | snapshots / patches |
| form submit / wizard step | medium | outside | patches + txId guard |
| canvas gesture / drag / drop | large | sometimes | commands (data) / replay |
| multi-step async workflow | large | yes | transactional replay |
Snapshots store states · patches store deltas · commands store inverses · replay stores intents.
Memory shrinks left → right. Semantic fidelity grows left → right.
snapshot / patches / command / replay — switch at runtime, watch history depth & memory react
ephemeral-in-tree · closure commands · async saves — each flips one named failure on
memory meter for 01 · flooded history for 02 · unserializable feed for 03 · rejected late tx for 04
Full interactive lab + course: ../course/index.html#lab
stores states · dies on 0102
stores deltas · fixes 01 · dies on divergence & semantics
stores inverses · fixes 0105 · dies on 03 closures & async
stores intents · fixes 01–05 · costs event design + purity
Undo isn't a data structure. It's a contract with your user about what ⌘Z means — and the architecture that honors it.
Course + interactive lab · deck + notes · verified research — all artifacts in this project folder.