← Blog

Undo across six sources of truth

  • architecture
  • react
  • state-management
  • undo

People kept clicking the wrong button.

In the canvas editor I've been building, selecting a layer reveals a row of alignment controls — align-left, align-center, the usual. And users kept reaching for the two icons on the far left of that row, expecting them to be undo and redo. They weren't. There was an undo in the app, bound to ⌘Z like you'd hope, but it only knew how to reverse one narrow thing: edits to the machine's "operation" list. Move a pattern across the canvas, nudge a slider, recolor a layer, resize the artboard — none of it could be undone. The muscle memory was right; the feature was missing.

So the task looked simple: make ⌘Z undo everything, including dragging a shape around. I figured a couple of hours. The first version of undo is always twelve lines — push the previous state onto a stack, pop to go back — and I'd half-written it in my head before I opened the editor.

Then I went looking for "the previous state," and discovered there was no such thing.

What "the document" actually was

I'd been imagining a tidy document object I could snapshot. What I had instead was a document smeared across six independent React hooks, each owning its own slice with its own useState:

  • layers — the array of patterns on the canvas
  • panels — the cut-layout regions
  • bgColor
  • operations — the machine instruction list
  • canvasSize — width, height, units, margins
  • and a few loose scalars in the top-level component

Nobody owned the whole thing. The "document" was an emergent property of those six hooks agreeing with each other at a given instant. My twelve-line stack had nothing to push.

This is the moment the task changed shape. The question was no longer "how do I store undo states." It was: where should the document live so that undo is even expressible? That's an architecture question wearing a feature's clothes.

What undo actually has to guarantee

Before choosing, I wrote down what a correct undo needed, independent of how I built it:

  1. Atomicity. Undoing a drag-then-recolor must restore both, together. A torn undo — position reverts but color doesn't — is worse than no undo.
  2. One gesture, one entry. Dragging a shape fires sixty times a second. The user did one thing; ⌘Z should reverse one thing, not sixty.
  3. Redo that survives. Undo, look around, change your mind, redo back to where you were — without losing the redo stack to a stray click.
  4. Cheap enough to not think about. My documents are tiny: a handful of layers, single-digit kilobytes.

That list is the whole post, really. Everything below is just satisfying those four with the smallest amount of new architecture.

The wrong turn I almost took

The obvious move, when state is scattered, is to un-scatter it. Pull all six slices into one central store, make the other hooks read from it, and let an undo layer observe that single source of truth. This is roughly what the serious tools do. tldraw runs a reactive central store precisely so its history manager can watch one stream of diffs.1 Penpot keeps a central app store with a command-and-inverse log.2 Figma models the document as one big property map and implements undo as recorded inverse operations.3 Every reference I admired pointed at "centralize, then log commands."

I started down that road. And then I noticed why all those systems centralize: they have problems I don't have.

Figma's design is shaped by multiplayer — undo has to mean "undo my change" without clobbering a collaborator's, which a wholesale state-swap would do.3 tldraw and Penpot centralize so they can compute cheap structural diffs over large documents, where snapshotting the whole thing on every edit would be too expensive. There's even a widely-cited essay arguing the snapshot ("memento") approach "doesn't work for anything besides the simplest of apps" — but its three reasons are all multiplayer, side-effects, and large-state concerns.4

My app is single-user. It has no side-effects on undo. A full document is about six kilobytes. By that essay's own criteria, I was squarely in the "simplest app" bucket where the snapshot approach is the right call, not the embarrassing one. I had been about to spend a week rewriting six hooks to buy a property I could get for free.

The reframe was this: atomicity doesn't come from owning the slices together. It comes from capturing and restoring them together.

One capture point, one restore point

I don't need a central store. I need a single atomic moment that reads all six slices in one synchronous pass, and a matching moment that writes them all back. The slices can keep living in their own hooks.

So the history engine owns nothing about the document. It owns two stacks and borrows two functions:

useHistory({
  capture,  // () => Snapshot   read all six slices, deep-cloned
  restore,  // (snap) => void   write all six slices back
  limit: 50,
})

capture() is one object literal in one file:

const capture = () => ({
  v: SCHEMA_VERSION,
  layers: clone(layers), panels: clone(panels), bgColor,
  operations: clone(operations), assignments: captureAssignments(),
  canvas: captureCanvas(),
});

That's the entire trick. The stack itself is the boring [past] present [future] you already pictured:

   past (undo)                 present            future (redo)
  [s0][s1][s2] .............. [ s3 ] ........... [s4][s5]
                                                  ^ cleared on any new edit

A drag-to-move and a slider tweak are now the same kind of entry — both are just "produce a new snapshot." I never had to write a MoveCommand and a RecolorCommand with hand-rolled inverses. That sameness is the strongest argument for snapshots at this scale: the operations that are hard to invert individually are trivial to capture wholesale.

The two bugs every history system warns you about

Reading real implementations paid off less in architecture and more in gotchas — the hard-won kind that are invisible until they bite.

Selection must not touch history. Excalidraw's history code is deliberately careful here: it won't clear the redo stack for a selection-only change, because a stray click to deselect could otherwise wipe every redo entry.5 If selecting a layer counts as a change, then looking at your work destroys your ability to redo it. The fix is to keep selection out of the snapshot entirely — clicking around is not an edit.

Coalescing needs a boundary, not just a timer. You can't merge a drag into one entry by debouncing alone, because a debounce will also happily merge two separate quick edits. Every mature system pairs a time window with an explicit close signal — ProseMirror groups edits within ~500ms but lets you force a boundary.6 So I open a pending entry on pointerdown, absorb every frame, and close it on pointerup. For typing in a field: a 400ms idle window, but also close on blur. The gesture defines the entry, not the clock.

There's a third, quieter risk that's specific to the capture/restore approach: if I add a seventh slice next year and forget to put it in capture(), undo will silently tear that slice. I bought insurance with one test — restore(capture()) must be a no-op, deep-equal across every slice. If someone forgets a slice, that test goes red the moment they add it.

Going further than the textbook

The conventional wisdom is that undo history is a within-session affordance — close the tab and it's gone. I'm breaking that rule on purpose. Because the documents are so small, the plan persists the stack: a trimmed tail to localStorage for everyone, and for signed-in users, the last few entries ride along inside the manually saved document, so you can reopen it on another machine and still undo your way back.

The catch with persisted history is staleness. A snapshot saved last month was authored under last month's data model. So two rails are non-negotiable: every persisted snapshot carries a schema version, and on load a mismatch silently drops the history while keeping the document — never restore a snapshot you can't trust. And every snapshot is run through the same migration the rest of the app uses before it's allowed to become the present. Old undo states are guests; they go through customs.

What it should cost

The engine lands at roughly forty lines. It replaces a single-purpose undo hook many times its size and collapses four separate code paths into one. A move and a recolor and a resize become indistinguishable to the history stack — which is exactly why there are no edge cases between them. And the two icons on the far left of that toolbar, the ones people keep reaching for, will finally do what everyone already assumes they do.

The thing I'll carry to the next project is the reframe, not the code. Undo isn't a feature you bolt on; it's a property of how you model change. I spent the first hour trying to build a better stack, and the turn came from a smaller question — not "where do I keep the history," but "what counts as one change, and can I capture all of it at once." Get that right and undo mostly falls out. Get it wrong and no amount of stack management saves you.

I almost rewrote the whole state layer to earn that. It turned out I only needed to read it all in the same breath.

Notes

Footnotes

  1. tldraw, "History" — the editor records diffs and marks against a reactive store, batching an interaction into one atomic entry. https://tldraw.dev/sdk-features/history

  2. Penpot stores each undo entry as paired redo/undo changes (a command and its inverse) in a central app store. https://github.com/penpot/penpot

  3. "How Figma's multiplayer technology works" — the document is a property map; undo is recorded inverse operations, and deleted-object data is "stored in the undo buffer of the client that performed the delete." The guiding invariant: "if you undo a lot, copy something, and redo back to the present (a common operation), the document should not change." https://www.figma.com/blog/how-figmas-multiplayer-technology-works/ 2

  4. Isaac Hagoel, "You Don't Know Undo/Redo" — argues the memento/snapshot pattern struggles for non-trivial apps; the cited reasons are multiplayer overwrite, side-effects, and local state outside the store. https://dev.to/isaachagoel/you-dont-know-undoredo-4hol

  5. Excalidraw's history implementation deliberately avoids clearing the redo stack on selection-only changes, so a stray click can't wipe the redo entries. https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/history.ts

  6. ProseMirror's history plugin groups adjacent transactions within a time window (newGroupDelay, ~500ms) and exposes closeHistory to force an entry boundary. https://prosemirror.net/docs/ref/#history