If one drag takes twelve presses of Undo to reverse, the history code probably recorded exactly what it was told to record.

Twelve changes.

The person using the editor remembers moving a block once.

I build Repceipt, a receipt editor that runs in the browser. You can move text around, resize elements, change fonts, and select several blocks at once. Undo has to make sense across all of those interactions.

The stack operations are straightforward. Deciding when to put something on the stack takes more thought.

A snapshot in the editor contains the document and its selection:

type Snapshot = { template: Template; selectedId: BlockId | null; selectedIds: BlockId[]; }; 

There are arrays for the past and future. An edit saves the current snapshot to the past and clears the future. Undo restores the last past snapshot and puts the current one at the front of the future. Redo moves it back.

This is the familiar approach described in the Redux undo history guide. Our store uses Zustand, but the same structure works there.

Notice what's missing from the snapshot: zoom, the active tool, and whether a panel is open.

Suppose you change some text, zoom in to inspect it, then press Undo. You probably want the old text back while staying zoomed in. Recording every store update would make you undo the zoom first.

Selection is a useful exception. Clicking a block doesn't create a history entry, but selection travels with document snapshots. Delete a selected block, then undo the deletion, and the block comes back selected. You can carry on working without finding it again.

Dragging needs a similar distinction between what appears on screen and what becomes an edit.

In Repceipt, the block follows the pointer through temporary transforms during the gesture. The saved document still contains the starting coordinates. When the drag ends, the temporary styles are cleared and the final movement is committed to the store.

That produces one history entry, however many movement events arrived along the way. A group drag commits the selected blocks together, so Undo moves them all back together too.

You could also update a temporary document throughout the gesture and record its starting state once. The important part is having a clear boundary around the action. You shouldn't have to guess whether a slow drag contains three edits or thirty.

Cancellation needs a boundary as well. On a phone, a second finger can turn a block drag into a pinch to zoom the canvas. Our editor cancels the block drag, restores its temporary transforms, and leaves history alone.

For an editor using pointer events directly, remember that a gesture can end with pointercancel, including when the browser takes over for scrolling or zooming. Decide how to restore the preview on that path, too.

Typing and keyboard nudges are a different case. The document should update as you type, but undoing a label one character at a time gets tedious.

The store groups consecutive updates when they have the same key and arrive less than 400 milliseconds apart. Here's the decision, stripped down:

const coalesce = key !== undefined && lastCommit?.key === key && now - lastCommit.at < 400; 

The first update saves the starting snapshot. Matching updates keep changing the document without pushing more snapshots. Each update refreshes the timestamp, so the window measures the gap between updates, rather than the total length of the typing session.

The key identifies what is changing. Editing a block's text has a different key from changing its size. Repeated arrow-key nudges use a key based on the selected block IDs.

The number alone wouldn't be enough. Two unrelated edits can easily happen within 400 milliseconds. They still need separate undo steps.

And that number is a product choice. Pause long enough while typing and you'll get another step. A text editor might want word boundaries or explicit editing sessions instead. For these short labels and repeated nudges, a small window keeps the implementation manageable. Drags already have start and end events, so they don't need the timer.

Undo and Redo also reset the grouping marker. Otherwise, a quick edit after Undo could accidentally join the group you just left.

There's another small detail that becomes obvious once you try this sequence:

  1. Move a block.
  2. Undo the move.
  3. Click the block without moving it.
  4. Press Redo.

The move should still be available.

A zero-distance drag exits before committing. Selecting the block doesn't commit either. Both decisions matter because a commit clears the future array. If a click records an unchanged document, it can silently throw away the redo history.

The movement code also checks the final delta after rounding and clamping. A pointer can move while the block ends up in the same place. That's still no change to the document.

Some real document updates should stay out of history, too.

When fonts finish loading, the editor measures text so selection outlines fit the glyphs the browser actually drew. That can update stored geometry. It shouldn't make the receipt appear modified, and Undo shouldn't restore an inaccurate selection outline.

That measurement has its own update path. It leaves history and the modified flag alone. There's a regression test for exactly that behavior: opening a template and measuring its text must leave the undo stack empty.

Snapshots also depend on keeping old objects intact. Our edits create new document and block objects, leaving previous versions available to history. Mutating a shared block would change what an older snapshot points to. React's guide to updating objects in state explains why treating those objects as read-only matters.

We cap history at 80 snapshots. That bounds the number of retained versions; it isn't a guarantee about memory usage, especially if a document contains large assets.

When checking undo behavior, these are the sequences I'd start with:

  • Drag several blocks, then undo once. Every block should return.
  • Undo, change selection or zoom, then redo. The edit should still be there.
  • Undo, make a new document edit, then try Redo. The abandoned edit should be gone.
  • Start dragging on a phone, then add a second finger to pinch. The block should stay at its original position.
  • Open a document and wait for its fonts. Undo should remain unavailable until you edit something.

Those sequences say more about how the editor feels than a test that only checks whether an array got shorter.

For each new control, I now want an answer to one question before wiring up its change handler: what should one press of Undo reverse here?