←→ navigate · G grid · N notes · F fullscreen
presenter notes
overview — click a slide

Undo isn't the
opposite of do.

Designing history for async React — four undo architectures for a drag-and-drop dashboard, and the transactional replay model that survives them all.

Thu · Aug 27 · 18:00 IDTMatia · Beit Gibor Sport, Menachem Begin 7React 19 · TypeScript · dnd
the trap

Undo looks like a stack…

…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.

the rubric

Five ways undo dies

01

Memory growth

Full-state snapshots scale with state × depth. 2 MB tree × 50 = 100 MB retained.

02

Polluted history

Drag position, hover, focus in the same tree → one gesture = 200 undo steps of nothing.

03

Non-serializable commands

Closures capture component scope. Can't persist, inspect, or replay.

04

Late async completions

Network resolves after the user undid. The write lands in a world history no longer describes.

05

Ambiguous batching

One intent = N internal dispatches. Undo reverses a fraction of a gesture.

the fix

Choose the unit first

What does your user expect ⌘Z to reverse? That answer is a UX contract. Architecture follows it.

chapter 01

Pick the unit your user hears

1

Keystroke

text editors — ProseMirror steps

2

Field edit

forms, config, inspectors

3

Gesture / transaction we live here

dashboards, canvases, DnD — one committed intent

4

Document version

save points, version control

design 01 / 04

Full snapshots — the Memento

 snapshot-history.ts
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
}
design 01 · failure

Snapshots die at scale

failure 01

Memory: O(state × depth)

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.

failure 02

Pollution: ephemeral state

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.

design 02 / 04

Immer patches — store the delta

 patch-history.ts
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)
design 02 · failure

Deltas aren't semantics

divergence

Patches replay onto their exact tree

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.

no meaning

A diff log answers "where", not "what"

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.

design 03 / 04

Commands + inverse — the Command pattern

 command-history.ts
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 03
design 03 · failure

Closures hold history hostage

failure 03

Commands as closures

Not serializable. Not inspectable. Not replayable after refresh. And they leak props that went stale three re-renders ago.

inverse burden

Async has no inverse

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.

design 04 / 04 · the flip

Stop storing states.
Store intents.

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

design 04 · the engine

The replay engine

 replay-history.ts
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
}
design 04 · anatomy

Five mechanisms, five fixes

mechanismmeaningkills
Semantic eventslog card.moved {id,to}, not structural diffs05 batching
txId at intent timemint on pointerdown — before any await04 late async
Atomic publicationone gesture → one append, N mutations inside05 batching
Closure-free payloadsevents are JSON — inspect, persist, replay03 closures
Rolling checkpointevery K events: snapshot + truncate prefix01 memory

Pollution (02) dies automatically: ephemeral updates are never events — they don't reach the log.

failure 04 · in motion

When the network lands late

1

t₀ — user moves card

txId minted at intent time · event published · save starts

2

t₁ — user presses undo

cursor rewinds · projection re-runs · UI correct

3

t₂ — save resolves the moment of truth

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.

chapter 06

React 19 made async first-class — undo must catch up

pattern

Optimistic append, guarded completion

Publish at intent time. The network result verifies or reopens — never splices behind the cursor.

pattern

isPending is not history

Pending flags are view-layer. Put them in the tracked tree and failure 02 walks back in.

pattern

Two-phase: transition + commit

Render a thousand frames during a drag. Publish one event. startTransition marks the interactive path; history records the intent.

pattern

Late result = new event

A completion for a tx that's no longer head becomes a fresh event the user can undo. Append-only, always.

chapter 08

Choosing your unit

your unit is…stateasync inside?choose
keystroke / field editsmallnosnapshots / patches
form submit / wizard stepmediumoutsidepatches + txId guard
canvas gesture / drag / droplargesometimescommands (data) / replay
multi-step async workflowlargeyestransactional replay

Snapshots store states · patches store deltas · commands store inverses · replay stores intents.
Memory shrinks left → right. Semantic fidelity grows left → right.

chapter 07 · live demo

The lab: make failures visible

A

One dashboard · four engines

snapshot / patches / command / replay — switch at runtime, watch history depth & memory react

B

Failure switches

ephemeral-in-tree · closure commands · async saves — each flips one named failure on

C

Watch it break

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

summary

Four designs, one axis

1

Full snapshots — Memento

stores states · dies on 0102

2

Immer patches — deltas

stores deltas · fixes 01 · dies on divergence & semantics

3

Commands + inverse — Command

stores inverses · fixes 0105 · dies on 03 closures & async

4

Transactional replay — event sourcing

stores intents · fixes 01–05 · costs event design + purity

takeaways

1 · Choose the unit first.
2 · Undo is do, shorter.
3 · Make failures visible.

Undo isn't a data structure. It's a contract with your user about what ⌘Z means — and the architecture that honors it.

Thanks.
Now go break your undo.

Course + interactive lab · deck + notes · verified research — all artifacts in this project folder.

Q&A — ask about anythingUndo Isn't the Opposite of Do · Designing History for Async React