Undo isn't the
opposite of do.
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. This course takes you through four undo designs for a React 19 drag-and-drop dashboard — snapshots, Immer patches, commands with inverses, and a transactional, forward-only replay model — and makes every failure visible along the way.
Why undo breaks
A stack of previous states is the mental model everyone has. push(state) on every change, pop() to go back. It works for a counter. It dies in a real app — and it dies in five specific, visible ways. Every design in this course is an attempt to fix some of them, and every design keeps at least one.
Memory growth
Whole-state snapshots copy everything. A 2 MB dashboard tree × 50 history entries = 100 MB of retained memory — per tab. Snapshots scale with state size × history length.
Polluted history
Ephemeral state — drag position, hover, focus, `isPending` — lives in the same tree. One gesture floods the stack with 200 internal-mutation entries that undo backwards pixel-by-pixel.
Non-serializable commands
Commands that capture closures hold component scope hostage. They can't be inspected, persisted, or replayed after refresh — and they leak whatever they closed over.
Late async completions
User clicks, a network write starts, user undoes, the write resolves. The completion handler writes into state the history no longer describes. Ghost state, corrupted undo.
Ambiguous batching
One user intent ("move this card here") emits N internal dispatches. Record each one and undo reverses a fraction of an intent. Your users expect to undo the gesture, not its 7th frame.
Undo is a product decision
What undo reverses — the unit — is a UX contract, not an implementation detail. Choose the unit first. The architecture follows. That's the whole talk.
The Unit Ladder
Before architecture, pick the unit of reversibility. From finest to coarsest:
The unit you pick is the answer your user hears when they press ⌘Z. Press undo after dragging a card: they expect the card to return — not the drag-shadow to jump 3 pixels left. The dashboard's unit is the transaction: one committed gesture.
Full snapshots — the Memento
The classic: before every change, copy the entire state tree and push it on the history stack. Undo = pop and restore. It's the Memento pattern — originator produces a snapshot, caretaker stores the stack. You already know it; that's the problem: it's the design people ship without choosing.
// Design 1 — full snapshots. Simple, and wrong at scale.
let past: DashboardState[] = [];
let future: DashboardState[] = [];
function commit(next: DashboardState) {
past.push(current); // whole tree, every time
if (past.length > 50) past.shift();
future = [];
current = next;
}
function undo() {
if (!past.length) return;
future.push(current);
current = past.pop()!; // restore a full copy
}Where it dies
O(state × history)
Every entry pays for the whole tree. Structural sharing (the immutability trick) helps re-rendering but not here: you're literally storing full copies on purpose.
Ephemeral fields leak in
If dragPosition is part of state, every pointermove commits a snapshot. 200 entries of nothing.
Verdict — snapshots
Use when
- State is tiny and flat
- Undo is rare (wizard steps)
- You need simplicity today
Fails when
- Large trees (canvases, docs)
- High-frequency gestures
- Ephemeral state in-tree
partialize — choose which fields history tracks (kills pollution) — and limit — cap history depth (bounds memory). The failures are so universal that the ecosystem built band-aids for them.Immer patches — store the delta
Don't store the tree — store the difference. Immer runs your reducer inside a proxy that records every mutation as a minimal, JSON-serializable patch: {op, path, value}. You get inverse patches for free: undo = apply inverse patches, redo = apply patches. Memory now scales with the change, not the state.
import { produceWithPatches, applyPatches, enablePatches } from "immer";
enablePatches(); // required since Immer v6
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 operation, generated, minimal, serializable ✨
function undo() {
state = applyPatches(state, history.pop()!.inverse); // apply the inverse
}
function redo() {
state = applyPatches(state, future.pop()!.patches); // apply the delta
}Where it dies
Patches replay onto their exact tree
A patch is an address + a value. If anything else touched that address — a late async completion, a concurrent actor — applyPatches writes into a world the patch never knew.
A diff log has no semantics
Patches record where bytes changed, not what the user did. You can't answer "which transaction does this patch belong to?" — so you can't build unit-level undo on them.
Verdict — patches
Use when
- State is big, changes are small
- Single writer, no network races
- You need memory efficiency
Fails when
- Async writes overlap undo
- You need transaction semantics
- Multiple actors share the tree
Commands + inverse — the Command pattern
Model each operation as an object: what it does, what undoes it, both callable. Undo = pop the history and call command.undo(). This is the Command pattern from GoF — and it's how ProseMirror (the editor engine behind rich-text UIs) builds its undo history: every edit is a step that can be inverted.
interface Command {
do(): void;
undo(): void; // explicit inverse — you write it
}
class MoveCard implements Command {
do() { cards[this.id].column = this.to; }
undo() { cards[this.id].column = this.from; } // its inverse
}
history.push(new MoveCard(id, from, to));
function undo() { history.pop()!.undo(); }Where it dies
Closures are hostage-takers
The naive version stores { do: () => {...}, undo: () => {...} } — closures over component scope. Not serializable, not inspectable, not replayable after a refresh, and they leak props that are already stale.
Inverses of async commands aren't inverses
Undoing a network write isn't write.reverse() — it's a compensating action that can itself fail. Inverse correctness is now your permanent job.
step.invert(doc)), so steps are serializable, debuggable, and replayable. Commands as data survive; commands as closures don't.Verdict — commands
Use when
- Operations have clean inverses
- You want semantic undo units
- Data-only commands (ProseMirror style)
Fails when
- Async ops need compensation
- Closures capture mutable scope
- One intent = many commands
Transactional replay — forward-only history
Now the mental-model flip. Stop storing before-states or inverses. Store an append-only log of semantic events — what the user did — and treat the current state as a projection of the log. Undo isn't the opposite of do. Undo is do, shorter: re-project the state from the log up to N−1. Redo is the same operation at N+1. One engine, one code path, no inverses to maintain.
type Event = {
txId: string; // assigned at INTENT time (pointerdown)
type: "card.moved" | "card.added" | ...; // semantic, not structural
payload: Json; // plain data — no closures
ts: number;
};
const log: Event[] = []; // append-only
let cursor = -1; // projection position
let checkpoint = { index: -1, state: initial }; // rolling
function commit(e: Event) {
log.push(e); // atomic publication: 1 intent = 1 append
cursor = log.length - 1;
if (log.length % CHECKPOINT_EVERY === 0) checkpoint = { index: cursor, state: state };
}
function project(to: number) { // undo AND redo are this one function
const from = checkpoint.index > to ? 0 : checkpoint.index;
let s = from === 0 ? initial : checkpoint.state;
for (const e of log.slice(from + 1, to + 1)) s = apply(s, e); // forward only
state = s; cursor = to;
}The five mechanisms, and which failure each kills
| Mechanism | Meaning | Kills |
|---|---|---|
| Semantic events | Log card.moved {id,to}, not structural diffs | 05 batching |
| txId at intent time | Mint the ID on pointerdown — before any await | 04 late async |
| Atomic publication | One gesture → one append, even with N internal mutations | 05 batching |
| Closure-free payloads | Events are JSON — inspectable, persistable, replayable | 03 closures |
| Rolling checkpoint | Every K events, snapshot + truncate the prefix | 01 memory |
Verdict — replay
Use when
- Undo unit = user intent (gestures)
- Async/network inside the unit
- You want ONE undo code path
Costs
- Events must be designed (schema)
- Replay must be pure + fast
- More upfront architecture
React 19: why async made this urgent
Classic undo assumed synchronous, single-dispatch updates: click → reducer → state → history push. React 19's actions and transitions break that assumption by design. An action can await, overlap another action, and commit in two phases. That's failure 04 (late completions) turned into a first-class language feature.
const [isPending, startTransition] = useTransition(); // React 19
function onDrop(card, to) {
const txId = crypto.randomUUID(); // intent time — before awaits
startTransition(() => {
dispatch({ type: "card.moved", txId, card, to }); // optimistic
});
await save({ txId, card, to }); // network — may resolve LATE
}
// completion handler, 800ms later:
if (history.headTxId === txId) commit(txId); // still on top → publish
else /* user undid meanwhile */ discardOrReopen(txId);Optimistic append, guarded completion
Publish the event at intent time (optimistic), let the network result verify or reopen. Undo always operates on intent-level truth, never on network arrival order.
isPending is not history
isPending belongs to the view layer. Never store it in the tree your history tracks — that's failure 02 sneaking back through the front door.
Two-phase: transition + commit
Transitions mark interactive updates; the history commit happens once, at intent completion. Render a thousand frames during a drag — publish one event.
Late result = new event
If a completion arrives for a tx that's no longer head, never splice it behind the cursor. Either discard it or append it as a fresh event the user can undo. The log stays append-only.
The Lab — make the failures visible
A real drag-and-drop dashboard running all four engines. Drag cards between columns, add and delete, then switch engines and press undo. Watch memory, history depth, and the transaction timeline react live. Toggle the failure switches to see each design break exactly where the theory says it will.
| Try this in the lab | Engine | What breaks |
|---|---|---|
| Drag a card slowly across the board, then hit undo repeatedly | snapshot | 02 pollution — undo walks back frame-by-frame |
| Add 15 cards, watch the memory meter, then switch engines | snapshot | 01 memory — O(state × depth) |
| Enable closure commands, add a card, inspect the event feed | command | 03 closures — command can't be serialized |
| Enable async save, move a card, undo within 1.5s | any | 04 late async — completion arrives after undo |
| Move a card once, count history entries in each engine | all | 05 batching — intent vs internal mutations |
Choosing your unit
The talk refuses to prescribe one pattern — and so does this course. The question that matters: what does your user expect ⌘Z to reverse? Answer it, then walk the ladder.
| Your unit is… | State size | Async in unit? | Choose |
|---|---|---|---|
| a keystroke / field edit | small | no | snapshots or patches |
| a form submit / wizard step | medium | yes, outside | patches + txId guard |
| a canvas gesture / drag / drop | large | sometimes | commands (data-only) or replay |
| a multi-step async workflow | large | yes, inside | transactional replay |