Frontendistim · Aug 27 2026 · Ramat Gan

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.

Thu, Aug 27 · 18:00 IDT Matia · Beit Gibor Sport, Menachem Begin 7 React 19 · DnD · TypeScript 9 chapters · ~75 min
Chapter 00

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.

Failure 01
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.

Failure 02
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.

Failure 03
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.

Failure 04
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.

Failure 05
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.

The punchline
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.

i
Why a drag-and-drop dashboard is the perfect laboratoryOne drag gesture is one intent but dozens of state updates (position every pointermove, over/leave events, optimistic drops). It contains every failure at once: async, ephemeral state, and N-mutations-per-intent. If your undo design survives DnD, it survives your app.
Chapter 01

The Unit Ladder

Before architecture, pick the unit of reversibility. From finest to coarsest:

fig 1 · the unit ladder
Keystroke
Field edit
Gesture / transaction
Document version
text editors
forms, config
← dashboards, canvases
save points, VCS

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.

Rule of thumbThe unit you pick is the unit you must be able to name, count, and bound. If you can't say "undo reverses exactly one X", you haven't chosen a unit — you have a stack of hope.
Design 01 of 04

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.

snapshot-history.ts
// 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
}
fig 2 · history as a stack of full trees
each frame = complete copy of state · memory = state_size × depth

Where it dies

Memory growth
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.

Pollution
Ephemeral fields leak in

If dragPosition is part of state, every pointermove commits a snapshot. 200 entries of nothing.

memory / 50 ops
≈ 100 MB
patch-based design
≈ 6 MB
FIT: LOW
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
i
Already solved at the library levelZundo (undo/redo middleware for Zustand, <700 B) ships two knobs that patch exactly these two failures: 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.
Design 02 of 04

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.

patch-history.ts
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

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

No meaning
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.

Where it shinesSingle-actor, low-frequency, structured edits — forms, settings, inspectors. Immer patches are the correct answer for delta storage; they're just not a history semantics. Remember: they fix failure 01 (memory), do nothing for 03–05, and are fragile against 04.
FIT: MEDIUM
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
Design 03 of 04

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.

command-history.ts
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

Failure 03
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.

Inverse burden
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.

The ProseMirror fixSteps are plain data, not closures: a step object plus a registry of interpreters. The inverse is derived from the data (step.invert(doc)), so steps are serializable, debuggable, and replayable. Commands as data survive; commands as closures don't.
FIT: MEDIUM
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
Design 04 of 04

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.

fig 3 · the replay engine
event log · striped = checkpoint · ▾ = cursor (current projection)
projection
state = ∅
replay-history.ts
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

MechanismMeaningKills
Semantic eventsLog card.moved {id,to}, not structural diffs05 batching
txId at intent timeMint the ID on pointerdown — before any await04 late async
Atomic publicationOne gesture → one append, even with N internal mutations05 batching
Closure-free payloadsEvents are JSON — inspectable, persistable, replayable03 closures
Rolling checkpointEvery K events, snapshot + truncate the prefix01 memory
i
This is event sourcingMartin Fowler's three facilities of event sourcing — complete rebuild (re-run the log), temporal query (project to any point), event replay (re-project after corrections) — are the same three things your ⌘Z does. Version control is event sourcing; your undo history can be too.
FIT: HIGH
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
Chapter 06

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.

useTransition.tsx
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);
Pattern
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.

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

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

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

Chapter 07 · Interactive

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.

Backlog
Doing
Done
Engine
snapshot
History entries
0
Memory held
0 B
Undo granularity
Failure switches
Event feed
Try this in the labEngineWhat breaks
Drag a card slowly across the board, then hit undo repeatedlysnapshot02 pollution — undo walks back frame-by-frame
Add 15 cards, watch the memory meter, then switch enginessnapshot01 memory — O(state × depth)
Enable closure commands, add a card, inspect the event feedcommand03 closures — command can't be serialized
Enable async save, move a card, undo within 1.5sany04 late async — completion arrives after undo
Move a card once, count history entries in each engineall05 batching — intent vs internal mutations
Chapter 08

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 sizeAsync in unit?Choose
a keystroke / field editsmallnosnapshots or patches
a form submit / wizard stepmediumyes, outsidepatches + txId guard
a canvas gesture / drag / droplargesometimescommands (data-only) or replay
a multi-step async workflowlargeyes, insidetransactional replay
The one-line summarySnapshots store states, patches store deltas, commands store inverses, replay stores intents. Memory shrinks left to right; semantic fidelity grows left to right. Pick the column your unit lives in — and make the failures visible, because the failures are the design.