Design undo and redo
One undo and redo system that a canvas editor, a kanban board, and a form builder can all reuse. The hard parts are choosing commands over snapshots, deciding what one step is, keeping memory bounded, staying correct while every edit autosaves, and undoing only your own changes while teammates edit the same document.
The question
Our product has three editors: a canvas of shapes and connectors, a kanban board, and a form builder. Users in all three keep asking for undo and redo. Design it once, as a module every editor can use: the keyboard shortcuts, the buttons, and everything behind them.
Every edit already autosaves to our server, and up to 10 people can edit the same document at once. A canvas can hold 5,000 shapes.
Real-life examples: Cmd+Z in Figma while teammates edit the same file, Undo in Google Docs, which never removes a collaborator's text, Undo on Excalidraw and Miro boards, The Undo button in Gmail's toast after you archive an email
Requirements exploration
Undo looks like a two-line feature: keep a list of old states, go back one when the user presses Cmd+Z. That version works in a to-do app demo. It breaks in a real editor the first time a user drags a shape (60 history entries for one drag), types a sentence (one entry per letter), or presses undo while a teammate is editing (the teammate's work disappears). So the first five minutes go to questions whose answers change the design.
Questions to ask first
- Whose changes does undo reverse? Mine only, or the last change anyone made? In a shared document the answer is always "mine", and that one answer is the hardest part of the design.
- What is one step? A whole drag, or every pixel of it? A word, a sentence, or each letter? The browser reports about 60
pointermoveevents a second during a drag on a 60 Hz screen (more on faster screens), so grouping is not optional. - What can't be undone? Sending an invite email, publishing a form, charging a card. These leave the browser, so we need a different answer for them.
- Does history survive a reload? Most editors say no. The document is safe on the server; the history is a convenience for the session.
- Does undo restore selection and scroll? Users expect the changed objects to be selected and visible after an undo, but they don't expect
Cmd+Zto undo a scroll. - How big can a document get? 50 cards on a board and 5,000 shapes on a canvas need different memory plans.
- Is this one editor or a shared module? Here it's shared, so the history module can't know what a card or a shape is.
Functional requirements
- Undo and redo every document change the user made: create, delete, move, resize, edit text, change a property, reorder.
- Group events into steps the user would name: one drag is one step, a burst of typing is one step, "Align 12 shapes" is one step.
- Shortcuts:
Cmd+ZandCmd+Shift+Zon macOS;Ctrl+Z,Ctrl+Y, andCtrl+Shift+Zon Windows and Linux. Toolbar buttons and an Edit menu entry with the step's name, such as "Undo move card". - Undo only your own changes when others edit the same document, without erasing theirs.
- Every undo and redo is saved to the server like any other edit.
- Actions with effects outside the document (emails, payments, publishing) get a short undo window instead of a history entry.
Non-functional requirements
| Requirement | Target | Why this number |
|---|---|---|
| Undo latency | Under 50 ms from key press to paint | Undo is pressed in rapid bursts; it must feel instant and keep INP well under 200 ms. |
| History depth | At least 100 steps, 200 by default | ProseMirror and CodeMirror both default to keeping 100 steps; users rarely go further. |
| History memory | Under 20 MB per open document | Leaves room for the document itself on a 4 GB laptop or a mid-range phone. |
| Document size | Up to 5,000 objects | The canvas case; a kanban board is about 500 cards. |
| Collaborators | Up to 10 editing at once | Your undo must keep working while others' edits keep arriving. |
| Correctness | Do then undo returns the exact same document | Any drift corrupts documents silently; this becomes a test. |
A quick estimate shows why grouping and size matter from the start. A busy user makes about one meaningful edit every 10 seconds, or roughly 1,000 steps in a three-hour session. Without grouping, the same session produces tens of thousands of raw changes: a single 2-second drag alone is 120 pointermove updates. So the 200-step limit covers about the last half hour of real work, which is what people actually undo.
Out of scope
- Version history ("restore this document as it was last Tuesday"). It's a server feature with stored snapshots, not session undo, though the two can share ideas.
- The text editor's own internal undo. A rich-text editor like ProseMirror or Lexical keeps its own history; we'll decide how it plugs in, but not rebuild it. The rich-text editor guide covers it.
- The sync engine itself. We assume edits already flow to the server as operations; the Figma and Google Docs guides cover how.
Check your understanding · Exercise 1
Which of these answers would change the design the most? Select all that apply.
What makes undo hard
Before picking any data structure, name the properties of the problem. Each one drives a later chapter, and an interviewer who hears them early knows you've built this before.
- Events are not steps. The browser speaks in
keydown,input, andpointermoveevents. The user thinks in actions: "I moved the card", "I typed a title". Someone has to turn hundreds of events into a handful of named steps. - Undo is a write. Pressing undo changes the document. That change must be saved, synced to other people, and checked by the server, just like the original edit. The server may refuse it.
- The document is shared. Between your edit and your undo, teammates may have changed the same objects. Undo has to reverse your change without destroying theirs, and sometimes it can't do both.
- Not everything belongs in history. Selection, scroll, zoom, open panels, and the current route all change constantly. Recording them makes undo useless. Ignoring them makes undo disorienting.
- Some effects leave the browser. An email that was sent can't be pulled back by popping a stack. Those actions need a time window or a compensating action instead.
- History grows without end. A three-hour session can produce about 1,000 steps, and tens of thousands of raw changes. Without a budget, history quietly becomes the biggest object in the tab.
- Several things own the keyboard. A focused text field has the browser's own undo stack. A rich-text editor has its own. The app has one more.
Cmd+Zmust go to the right one. - It's a shared module. The history can't know what a card, a shape, or a form field is. It must work on any document that can describe its changes.
Think first
Your teammate says: "Undo is client-side only. The server never needs to know." When is that true, and when is it wrong?
It's true for state that never reaches the server: a local drawing scratchpad, or a form that hasn't been submitted yet.
It's wrong as soon as edits autosave. If undo changes only local state, the next reload shows the edit again, and other people never see the undo. So undo must produce ordinary edits that go through the same save path as everything else. The server doesn't need to know they came from an undo, but it must receive them.
Notice what's missing from that list: drawing the buttons. The visible part of undo is two icons and a shortcut. Almost all the design work is in deciding what to record, how to keep it small and valid, and how it travels to the server and to other people. The rest of the guide takes these properties one at a time, starting with the most basic one: what a history entry stores.
Snapshots or operations: how to remember the past
The first real decision is what one history entry stores, and there are two families. Snapshots store what the whole document looked like. Operations store what changed and how to reverse it. Everything else (memory, server saves, multiplayer) follows from this choice.
The naive version and where it breaks
The simplest undo deep-copies the document before each change with structuredClone(doc) and pushes the copy onto a stack. Undo pops the stack and swaps the copy in. It's correct by construction, and for a form builder with 30 fields it's fine: each copy is a few kilobytes.
Now take the canvas. 5,000 shapes at about 300 bytes of JSON each is 1.5 MB per copy, and in the JavaScript heap objects take more room than their JSON text. 200 steps is over 300 MB of history to move three shapes now and then. Each copy also costs several milliseconds of cloning on every edit, and with multiplayer, swapping in an old copy erases every change a teammate made since.
Decision What does one history entry store?
What we pick: Operations with inverses. They're the only option that stays small at 5,000 shapes, can be saved to the server as ordinary edits, and can be adjusted when teammates edit the same things. Structural-sharing snapshots remain a fine choice for a single-user editor with a small document, and the history module can accept them through the same API; the shared document editors use operations.
Getting inverses for free with Immer patches
Hand-writing an inverse for every action is where command-based undo goes wrong. Immer, the library behind Redux Toolkit's reducers, can record changes as you make them. produceWithPatches runs your ordinary mutating code on a draft and returns three things: the next state, the forward patches, and the inverse patches.
A patch is a plain object describing one change, in a format close to JSON Patch (RFC 6902), except that path is an array of keys instead of a string. applyPatches(state, patches) replays a list of them. The feature is opt-in since Immer 6: call enablePatches() once at startup.
import { enablePatches, produceWithPatches, applyPatches } from "immer";
enablePatches(); // once, at startup
const [next, redo, undo] = produceWithPatches(doc, (draft) => {
draft.shapes["s1"].x = 100;
draft.shapes["s2"].x = 140;
});
// redo: [{ op: "replace", path: ["shapes", "s1", "x"], value: 100 }, ...]
// undo: [{ op: "replace", path: ["shapes", "s1", "x"], value: 0 }, ...]
history.push({ label: "Move 2 shapes", redo, undo });
setDoc(next);
// Later, when the user presses Cmd+Z:
const entry = history.undo();
if (entry) setDoc((d) => applyPatches(d, entry.undo));
Patches give us the size of operations with the convenience of snapshots, with two cautions. First, patches into arrays use indexes (["cards", 3]), and an index goes stale as soon as anything is inserted before it. That's why the document should be normalized: objects in maps keyed by stable ids, with order stored as a separate field such as a fractional position, as the normalization lesson and the kanban board guide explain.
Second, a delete's inverse is an add patch carrying the whole object. Deleting 500 shapes produces about 150 KB of inverse, which matters for the memory budget later.
| Model | Move 3 of 5,000 shapes | 200 such steps | Delete 500 shapes | Others' edits on undo |
|---|---|---|---|---|
| Full snapshot | about 1.5 MB | about 300 MB | about 1.5 MB | Erased |
| Structural sharing, plain objects | about 40 KB | about 8 MB | about 40 KB | Erased |
| Structural sharing, persistent map | about 1 KB | about 200 KB | about 20 KB | Erased |
| Operations with inverses | about 300 bytes | about 60 KB | about 150 KB | Kept |
Check your understanding · Exercise 2
A teammate proposes storing do() and undo() closures in the history instead of patches. Which problems would that cause? Select all that apply.
The history stack: past, present, and future
With operations chosen, the history itself is two stacks around the document. Past holds the steps you can undo, newest on top. Future holds the steps you've undone and can redo. The document itself is the present.
Undo moves the top of past to the top of future and applies its inverse ops; redo moves it back and applies its forward ops. The Redux docs describe the same shape (past, present, future) for snapshot-based undo. With operations, the present is simply the live document.
type Entry = { id: string; label: string; redo: Op[]; undo: Op[] };
type History = { past: Entry[]; future: Entry[] };
function record(h: History, e: Entry): History {
return { past: [...h.past, e], future: [] }; // a new edit clears redo
}
function undo(h: History, apply: (ops: Op[]) => void): History {
const e = h.past.at(-1);
if (!e) return h;
apply(e.undo);
return { past: h.past.slice(0, -1), future: [...h.future, e] };
}
function redo(h: History, apply: (ops: Op[]) => void): History {
const e = h.future.at(-1);
if (!e) return h;
apply(e.redo);
return { past: [...h.past, e], future: h.future.slice(0, -1) };
}
Try it below. Make a few edits, undo some, then make a new edit and watch the future stack empty. Switch the model to see what each entry would cost on a 5,000-shape canvas, turn grouping on and off, and lower the limit to watch the oldest entries fall off.
What clears the redo stack
The rule "a new edit clears redo" needs care about what counts as a new edit. Get it wrong one way and redo vanishes when the user just clicked somewhere. Get it wrong the other way and redo replays a change on top of a document it no longer fits.
| Event | Clears redo? | Why |
|---|---|---|
| You move, type, delete, or paste | Yes | Your new change branches away from the undone steps. |
| You select, scroll, or zoom | No | Nothing in the document changed; redo still fits. |
| A teammate's edit arrives | No | It isn't your branch. Redo entries get adjusted against it, like undo entries (see the multiplayer chapter). |
| The server rejects one of your edits and it's rolled back | No | A rollback isn't a user edit; it removes the rejected step instead. |
| You reload the page or close the document | Yes, history is gone | History lives in memory unless you persist it deliberately. |
Branching history, and why most apps throw the branch away
Say you make edits A, B, C, D, undo twice back to B, then make edit E. C and D are now on a branch nobody can reach with undo and redo. Most editors discard them, and users rarely notice. But it does lose work: a user who undid a little too far and then nudged something has lost C and D for good.
Decision What happens to undone steps when the user makes a new edit?
What we pick: Linear history that discards the branch. It matches what users expect, and the real safety net for lost work is version history on the server, which keeps named and periodic versions of the document regardless of the session's undo stack.
Check your understanding · Exercise 3
You made edits A, B, C, then pressed undo once. Which of these should keep C available to redo? Select all that apply.
What counts as one undo step
Users undo intentions, not events. If one drag records 60 entries, the user presses Cmd+Z 60 times to move a card back, and decides undo is broken. If a whole minute of typing is one entry, one undo wipes a paragraph. Grouping decides where one step ends and the next begins.
group() call each make a single step. During the drag the document updates on every move, but history records only where the card started and where it ended.Decision How do we turn a stream of changes into steps?
What we pick: Combine them. Gestures and commands use explicit transactions, so a drag, a paste of 20 shapes, or "Align 12 shapes" is always one step. Text uses time-based coalescing at 500 ms, cut early at semantic boundaries and capped at a maximum group size so one long burst never becomes one giant step. Any code can force a break with history.checkpoint().
A drag as one step
On pointerdown the board opens a transaction labelled "Move card". Each pointermove updates the card's position in the document so other views and collaborators see it move, but the history records nothing yet. On pointerup the transaction commits one entry whose undo is "x back to where it started" and whose redo is "x to where it ended".
On Escape or pointercancel (the browser took the pointer for a scroll, or the window lost focus) the transaction cancels: it applies the inverse and records nothing. Held arrow-key nudges work the same way, closing the group about 500 ms after the last key, as the Figma guide describes.
How two entries merge
Merging two operation entries is mechanical. The forward ops run in order, so the merged redo is the first entry's redo followed by the second's. Undo runs backwards, so the merged undo is the second entry's undo followed by the first's. If both entries set the same field, compact them (keep only the last new value and the first old value), which turns 60 position updates into one pair.
function merge(a: Entry, b: Entry): Entry {
return {
...a,
redo: compact([...a.redo, ...b.redo]), // a then b
undo: compact([...b.undo, ...a.undo]), // undo b first, then a
};
}
// A later "replace" of the same field overwrites the earlier one.
function compact(ops: Op[]): Op[] {
const out: Op[] = [];
let seen = new Map<string, number>(); // path -> index in out
for (const op of ops) {
const key = op.path.join("/");
const i = seen.get(key);
if (op.op === "replace" && i !== undefined) {
out[i] = op; // nothing in between touched this path
continue;
}
// Adds, removes, and writes to a parent or child path are barriers.
const overlaps = [...seen.keys()].some((k) => k.startsWith(key + "/") || key.startsWith(k + "/"));
if (op.op !== "replace" || overlaps) seen = new Map();
if (op.op === "replace") seen.set(key, out.length);
out.push(op);
}
return out;
}
Rules for typing
- Start a new step if more than 500 ms passed since the last keystroke.
- Start a new step after a word boundary: the first letter typed after a space, punctuation, or newline.
- Start a new step when the kind changes: typing after deleting, or deleting after typing.
- Start a new step when the caret jumps somewhere not adjacent to the last edit.
- Cap a group at about 2 seconds or 100 characters, so continuous fast typing still produces several steps.
The recorder as a state machine
Transactions, coalescing, and applying undo all touch the same stacks, and bugs hide where they overlap: an undo pressed in the middle of a drag, or the ops that undo itself applies being recorded as a brand new edit. Modelling the recorder as a small state machine, as the state machines lesson recommends, makes those cases explicit. While undo or redo is applying, recording is off. While a transaction is open, undo first cancels it.
applying state is what stops undo's own ops from being recorded as a new edit, which would clear redo. Undo pressed mid-drag never mixes with the open transaction: it cancels it.Check your understanding · Exercise 4
A user drags a card for 2 seconds, then presses Escape before releasing. What should happen? Select all that apply.
What is undoable, and what isn't
Undo promises "put it back the way it was". That promise only holds for state the app fully controls. So for every kind of state, decide: is it a step, is it restored alongside a step, or is it outside history entirely?
| State | In history? | What undo does with it |
|---|---|---|
| Document content: shapes, cards, fields, text | Yes, as steps | Reverses the change. |
| Selection | Stored with each step, never a step itself | Restores the selection from before the step, so the user sees what came back. |
| Scroll and zoom | No | If the changed objects are off-screen, scrolls them into view. Never "un-scrolls". |
| Open panels, hover, menus | No | Nothing. |
| Route and current page | No | Browser back and forward own navigation. Undo never navigates. |
| Effects outside the app: emails, payments, publishing | No | Handled by a delay window or a compensating action, below. |
Selection deserves a closer look. If selection changes were steps, a user who clicked around five shapes and then pressed undo would get five useless undos before anything in the document changed. But if undo ignores selection completely, undoing a delete brings back a shape the user can't find.
The middle path: each entry stores selectionBefore and selectionAfter. Undo restores selectionBefore, redo restores selectionAfter, and either one scrolls the restored objects into view if they're outside the viewport.
Edits on another page
A canvas with several pages, or a form builder with several steps, raises a question: if my last edit was on page 2 and I'm now on page 3, what does undo do? One history per page means undo only reverses what's on the current page. That's predictable, but surprising when you just made a change and switched pages.
One history per document means undo can change something you can't see, so it must switch to page 2 first and select the restored objects. Design tools commonly keep one history per document and bring the change into view, and this design does the same. Either way, switching pages is never a step.
Actions with effects outside the document
Some actions on the board reach the outside world: inviting a teammate sends an email, and publishing a form makes it public. A history entry can't recall an email. Pretending otherwise is the worst outcome: undo shows the invite gone, and the email is already in the teammate's inbox.
Decision How do we handle an action whose effect leaves the app?
What we pick: Delayed commit for actions with a short natural window (invites, sends, bulk deletes on the server), exactly like the undo toast in the toast notifications guide: the toast's Undo cancels the pending request. Compensating actions for long-lived effects like publishing, labelled honestly ("Unpublish", not "Undo"). Neither goes on the Cmd+Z stack, because Cmd+Z must be safe to mash.
Check your understanding · Exercise 5
Which of these should be steps on the Cmd+Z stack? Select all that apply.
Keeping history inside a memory budget
History has no natural end: a designer working for three hours can make about 1,000 steps. Operations keep most steps tiny, but a few are large. Deleting 500 shapes stores all 500 so undo can restore them (about 150 KB), and pasting a big table into the form builder can be more. Without limits, history becomes the largest thing in the tab, and nobody notices until a laptop starts swapping.
Two limits, not one
A limit on entries alone (keep 200) is what most libraries offer, and it's a good start. But 200 tiny moves is about 60 KB, while 200 big deletes is about 30 MB. So the history keeps two limits: a maximum number of entries (200) and a maximum estimated size in bytes (20 MB).
When either limit is exceeded, the oldest entries are dropped until both fit. The oldest end is the right one to cut: users undo recent mistakes, almost never something from an hour ago.
const MAX_ENTRIES = 200;
const MAX_BYTES = 20 * 1024 * 1024;
// Estimated once, when the entry is committed.
function estimateBytes(e: Entry): number {
return 2 * (JSON.stringify(e.redo).length + JSON.stringify(e.undo).length);
}
function enforceLimits(h: HistoryState) {
while (h.past.length > MAX_ENTRIES || h.bytes > MAX_BYTES) {
const dropped = h.past.shift();
if (!dropped) break;
h.bytes -= dropped.bytes;
}
}
Making big entries smaller
- Store references, not payloads. An image added to the canvas keeps its asset id in the op, never the bytes. The file stays on the server (and in the HTTP cache), so the entry is 200 bytes instead of 2 MB.
- Compact before storing. Fold repeated replaces of the same field when a group commits, as in the grouping chapter. A run of 40 arrow-key nudges on 3 shapes, coalesced into one step, shrinks from 120 ops to 3.
- Tombstones instead of copies. If deleted objects stay in the document as tombstones, the undo of a delete is "set
deletedto false", not a full copy of the object. That moves the cost into the document, where it's shared with sync and version history. - Compress old entries, maybe. Entries older than the last 20 could be serialized and compressed with the
CompressionStreamAPI. It's asynchronous and rarely worth the complexity when ops are already small; try the other three first.
Lifecycle and leaks
Keep one history per open document, in a Map keyed by document id, and delete it when the document closes. A history that outlives its document holds every deleted object forever. Entries must hold plain data only: no DOM nodes, no component instances, no closures over the old document. Any of those keeps far more memory alive than the entry's size suggests, and the byte estimate can't see it.
Think first
A user selects all 5,000 shapes and deletes them. Without tombstones, that one entry is about 1.5 MB. Should the history accept it, and what if one entry alone exceeds the 20 MB budget?
Accept the 1.5 MB entry: it's exactly the step the user most needs to undo, and older entries will be dropped to make room.
If a single entry exceeds the whole budget, keep it anyway and drop everything older. A history with one huge, very recent step is far more useful than one that silently refuses to record a mass delete. Log the event so you learn how often it happens.
Check your understanding · Exercise 6
History is at 200 entries and 19 MB. The user deletes a group worth 3 MB of ops. What should happen? Pick one.
Keys, buttons, and screen readers
Most undo happens from the keyboard, in bursts: people press Cmd+Z four or five times quickly and watch. So the shortcut has to be right on every platform, it has to go to the right history, and the result has to be visible and announced.
The shortcuts by platform
| Platform | Undo | Redo |
|---|---|---|
| macOS | Cmd+Z | Cmd+Shift+Z |
| Windows | Ctrl+Z | Ctrl+Y, and Ctrl+Shift+Z in many apps |
| Linux | Ctrl+Z | Ctrl+Shift+Z, and Ctrl+Y in many apps |
| iPhone and iPad | Three-finger swipe left, or shake | Three-finger swipe right |
// userAgentData.platform is "macOS"; navigator.platform is "MacIntel".
const isMac = /mac|iphone|ipad/i.test(
navigator.userAgentData?.platform ?? navigator.platform,
);
function onKeyDown(e: KeyboardEvent) {
if (e.isComposing || e.altKey) return; // IME input, other shortcuts
const mod = isMac ? e.metaKey : e.ctrlKey;
if (!mod) return;
const key = e.key.toLowerCase(); // Shift may report "Z"
const isUndo = key === "z" && !e.shiftKey;
const isRedo = (key === "z" && e.shiftKey) || (!isMac && key === "y");
if (!isUndo && !isRedo) return;
if (isNativeTextField(e.target)) return; // let the browser's undo run
e.preventDefault();
if (isUndo) history.undo(); else history.redo();
}
window.addEventListener("keydown", onKeyDown);
Ctrl+Z on a Mac isn't taken over.Native text fields have their own undo
Every input and textarea keeps its own undo stack, managed by the browser. If focus is in the form builder's "Field label" input and the user presses Cmd+Z, they mean "undo my last few letters", not "undo the field I added a minute ago". That native stack is fragile: values set from JavaScript (input.value = ...) are not on it, and they can break it.
The deprecated document.execCommand() is the one API whose edits (insertText, for example) land on the native stack, as MDN notes, and execCommand("undo") undoes the last step on that stack. Neither is a base for an app's history, which is one more reason to keep the native stack short-lived.
Decision Who handles Cmd+Z while focus is in a text field?
What we pick: Hybrid. It matches how desktop apps behave: undo inside a text box edits the text, undo outside edits the document. Press Escape or click out, and Cmd+Z undoes the whole rename in one step.
Editable elements get one more signal. When the user picks Edit, Undo from the browser's menu or the context menu, there's no keydown at all. Instead the browser fires a beforeinput event whose inputType is historyUndo or historyRedo (from the Input Events specification) on the focused input, textarea, or contenteditable element.
The specification makes these two input types cancelable, so ProseMirror and CodeMirror listen for them, cancel the browser's undo, and run their own history. Check event.cancelable before relying on preventDefault(), since MDN warns that some beforeinput events can't be cancelled. The event never fires on non-editable elements, so a canvas whose focus sits on a plain element must offer its own Edit menu and buttons.
input, the listener would have stepped aside and the browser's own text undo would run instead. The server only ever sees the ops, never the word undo.Buttons, labels, and the Edit menu
- Name the step. The tooltip and menu item say "Undo move card" and "Redo rename field", read from the top entry's
label. Users undo with more confidence when they know what will happen. - Disabled state. When
canUndois false, the button is disabled. Preferaria-disabled="true"over thedisabledattribute inside a toolbar, so keyboard and screen reader users can still find the button and hear why it does nothing. - Shortcut hints. Show
Cmd+ZorCtrl+Zin the tooltip for the user's platform, and setaria-keyshortcutson the button. - Show the change. After an undo, restore the selection, scroll the objects into view, and briefly highlight them. An undo that happens off-screen looks like a broken button.
role="status" region is hidden from view but read aloud.Announcing undo to screen readers
A sighted user sees the card jump back. A screen reader user hears nothing unless we say it. After each undo or redo, write a short message to a polite live region (an element with role="status" whose text changes are read aloud): "Undid move card", "Redid delete 3 shapes".
When the user presses undo five times in a second, announce only the last one, after about 150 ms of quiet, or the reader queues five messages. If undo removes the element that had focus (undoing "Add card"), move focus to a sensible neighbor, such as the previous card or the column, never to the page body. The screen readers and keyboard navigation lessons cover live regions and focus in depth.
Check your understanding · Exercise 7
Focus is in the form builder's "Field label" input. The user typed "Full nme" and presses Ctrl+Z on Windows. What should happen? Select all that apply.
Undo meets autosave and the server
Every edit in our editors autosaves. That turns undo from a local trick into a distributed one: the user's undo has to reach the server, other tabs, and other people, and the server can refuse it.
To the server, undo is just another edit
When the user undoes "Move card to Done", the history applies the inverse ops locally, and they go to the same outbox that sends every other edit: "set card 42's column to Doing, position a3". The server doesn't need an undo endpoint and doesn't need to know where the ops came from. That keeps one write path, one set of permission checks, and one conflict policy. It also means undoing right after an autosave simply triggers another save, which is correct: the document really changed again.
Decision How does an undo reach the server?
What we pick: Inverse ops through the normal outbox. Undo stays a purely client-side idea, and the server sees a clean stream of ordinary edits.
When the server refuses an undo
The undo's ops can fail like any edit: an admin locked the card, a teammate deleted the column the card would return to, or the user's role changed to viewer. Because the ops were applied optimistically, the client rolls them back when the server says no.
The question is what happens to the history entry. It's no longer valid, so it's dropped from the redo stack (where the undo had moved it), and the user is told why: "Couldn't undo move: the Doing column was deleted by Ana."
Ordering: undo must never overtake its edit
Fast users undo before the original edit has been saved. If the undo's ops took a different path, or were sent in parallel, the server could apply "move back" first and "move" second, leaving the card in the wrong column while the client shows it correctly. Sending everything through one ordered outbox, each batch with the version it was based on, rules this out.
A good outbox also cancels out pairs that never left. If "move" is still queued when its undo arrives, both can be dropped before sending.
Should history survive a reload?
Most editors drop history on reload, and users accept it, because the document itself is safe on the server. Persisting history is possible: write entries to IndexedDB as they're committed, keyed by document id, and load them on open. The trap is staleness: by the time the user returns, teammates may have changed the document many times, so old inverse ops no longer fit.
Store the document version with each entry, and on load keep only entries whose version is still current, or run them through the same transform as remote edits (next chapter). For most products the honest answer is no persisted undo, plus server-side version history for anything older. The IndexedDB lesson covers the storage side if you do need it, for example for an offline-first editor.
Check your understanding · Exercise 8
The user moves a card, the autosave succeeds, then the user presses undo and the server rejects the undo's ops because the card is now locked. Which statements are right? Select all that apply.
Undo when other people are editing
Up to 10 people edit the same document. Ana moves card 42 to Done, a second later Ben renames it, and then Ana presses Cmd+Z. She means "put my card back", not "undo whatever happened last" (Ben's rename), and certainly not "restore the board as it was before my move", which also erases Ben's rename and everything else since.
The operational transformation lesson calls this intention preservation: an operation should keep doing what its author meant, even after others' operations land around it. Try the three answers below and watch what happens to Ben's work.
Decision What does undo reverse in a shared document?
What we pick: Selective undo. Each history only holds the local user's steps, and the module asks the document layer to keep those steps valid as remote operations arrive. The document layer already knows how to do this, because it merges concurrent edits for sync.
Two kinds of data, two ways to stay valid
Properties of objects with ids (a card's column, a shape's fill, a field's label) need no position math. The op says "set card 42's column", and card 42 is card 42 no matter what others did. The only question at undo time is whether someone changed the same property after you.
The Figma guide works through that case and its redo rule: record the redo values at the moment of undo, so undo then redo always leaves the document unchanged. That guide writes your old value back anyway. For kanban cards and form fields we pick the other reasonable policy: skip any property someone else changed since your step, and say so ("Ben renamed this card after your change, so the name wasn't undone"). If nothing in the step still applies, skip the whole step, so the next Cmd+Z does something useful.
Positions in sequences (characters in a text, items in a list ordered by index) need transformation. Your inverse "delete 4 characters at 10" was correct when you typed them. If Ben inserts 5 characters at position 3 afterwards, the same text now sits at 15. Every remote op that arrives must shift the positions in your stored entries, both undo and redo, using the same transform function operational transformation uses for sync (the OT lesson covers how it's written).
With a CRDT such as Yjs, characters have stable ids instead of indexes, so an inverse refers to ids and never goes stale. Yjs ships a selective UndoManager: by default it tracks only local changes made without an explicit transaction origin (network providers usually tag remote updates with themselves as the origin), you can choose other trackedOrigins, and it merges changes made within its captureTimeout of 500 ms.
type TextOp =
| { type: "insert"; at: number; text: string }
| { type: "delete"; at: number; length: number };
// Shift a stored op so it still targets the same text after a remote op.
function shiftAgainst(op: TextOp, remote: TextOp): TextOp {
if (remote.type === "insert" && remote.at <= op.at) {
return { ...op, at: op.at + remote.text.length };
}
if (remote.type === "delete" && remote.at < op.at) {
const removedBefore = Math.min(remote.length, op.at - remote.at);
return { ...op, at: op.at - removedBefore };
}
return op; // remote op is after ours: no change
}
// On every remote op, rebase both stacks.
function onRemoteOp(remote: TextOp) {
for (const e of [...history.past, ...history.future]) {
e.undo = e.undo.map((op) => shiftAgainst(op, remote));
e.redo = e.redo.map((op) => shiftAgainst(op, remote));
}
}
Edge cases
- The object was deleted by someone else. Undoing your move of card 42 after Ben deleted it would resurrect a card he removed on purpose. Skip it and say why.
- Your undo depends on someone else's work. You added a card to a column Ben created; Ben then undoes the column. Your card goes with it (or moves to a fallback column, if the board has that rule). When you then undo "Add card", there is nothing left to undo: skip.
- Rebasing cost. Shifting 200 entries for every remote op is cheap for text (a few microseconds each), but batch it: apply remote ops in one pass per animation frame, not one pass per op.
- Remote ops never clear your redo. They adjust it, like the undo stack.
Check your understanding · Exercise 9
Ana typed "fast " at position 10 of a shared text. Ben then inserted "Very " (5 characters) at position 0. Ana presses undo. Which statements are right? Select all that apply.
Architecture, data model, and the history API
Now put the pieces together. The goal from the requirements was one module that the canvas, the kanban board, and the form builder all reuse. That forces a clear line: the history module knows about entries, groups, limits, and stacks; it knows nothing about cards or shapes. Everything domain-specific sits behind a small adapter each editor provides.
| Part | Owns | Doesn't own |
|---|---|---|
| Editor UI (canvas, board, form builder) | Rendering, gestures, opening and closing transactions around gestures | History stacks; it calls the command layer |
| Command layer | Turning an intent ("move card") into ops plus inverse ops and a label, usually with Immer patches | Sending anything; it only describes changes |
| Document store | The current document, normalized by id; applying ops; notifying subscribers | Which ops are undoable |
| History manager | Past and future stacks, grouping, limits, the recorder state machine, canUndo and canRedo | What an op means; it calls the adapter to apply and rebase |
| Document adapter | apply(ops), rebase(entry, remoteOp), stillApplies(op) for this editor's data | Stacks or UI |
| Undo UI and shortcuts | Key routing, buttons with labels, the Edit menu, the live region | Any document logic |
| Sync outbox | Sending local ops in order with base versions, receiving remote ops, reporting rejections | Whether an op came from undo |
| Effects queue | Delayed commits for outside effects (invites, sends) with their undo window | Document history |
Two boundaries matter most. First, the history manager sits beside the document store, not inside it. The store applies ops from three sources (local edits, undo and redo, and remote users), but only local edits add entries, and remote ops only rebase them.
Second, the outbox doesn't know about undo at all. That's what keeps one write path to the server: undo ops and normal ops are indistinguishable once they leave the history manager.
How data moves
Follow one edit and its undo end to end. A local edit flows from gesture to command to store, and the store reports it to history (as a labelled entry) and to the outbox (as ops). An undo flows from the shortcut to history, which hands the inverse ops through the adapter to the store, and the store sends them to the outbox like any edit. A remote op flows from the socket through the outbox to the store, and then to history, which rebases its entries against it.
Main thread, tabs, and lifetimes
The history manager runs on the main thread, next to the store. Its work per keypress is tiny (move one entry between two arrays, hand a few ops to the store), so a Web Worker would only add a message round trip to every undo. The expensive parts, rendering 500 restored shapes or transforming text, happen in the store and the view anyway.
Two tabs open on the same document each get their own history. To each other they're simply two collaborators: tab B's edits arrive at tab A as remote ops, rebase A's entries, and never appear in A's undo stack. That falls out of the design for free, with no BroadcastChannel coordination of stacks. The history is created when a document opens and dropped when it closes, so switching between ten documents in a session never stacks up ten histories in memory.
Think first
Why not put the history inside the document store, as a Redux reducer that wraps the document reducer?
For a single-user app with snapshots, that's exactly what libraries like redux-undo do, and it's fine.
Here, the store receives remote ops that must never become history entries, rejections that must remove entries, and grouping decisions that depend on gestures, not on actions. A separate history manager that subscribes to labelled local changes keeps those rules in one place, and the same manager works whether an editor uses Redux, Zustand, or a CRDT document.
Check your understanding · Exercise 10
A remote op arrives over the socket. Which parts does it pass through? Select all that apply.
The history data model
Inside that architecture, the history module stores plain data. Each entry is a labelled pair of op lists plus the context needed to restore the user's view. The state around it tracks the stacks, the open group, and the byte count for the budget.
type Op = { op: "add" | "remove" | "replace"; path: (string | number)[]; value?: unknown };
type HistoryEntry = {
id: string;
label: string; // "Move card", shown as "Undo move card"
redo: Op[]; // forward ops, in order
undo: Op[]; // inverse ops, in reverse order
selectionBefore: string[]; // object ids to reselect after undo
selectionAfter: string[]; // ... and after redo
pageId?: string; // where to navigate to show the change
baseVersion: number; // document version when recorded
bytes: number; // estimated once, for the budget
createdAt: number; // for time-based coalescing
mergeKey?: string; // "type:card-42:title" merges with the same key
};
type HistoryState = {
past: HistoryEntry[]; // newest last
future: HistoryEntry[]; // next redo last
open: { label: string; redo: Op[]; undo: Op[] } | null; // a transaction
mode: "idle" | "grouping" | "applying";
bytes: number;
limits: { maxEntries: number; maxBytes: number; mergeWindowMs: number };
};
A few fields earn their place. label is stored, not computed, because by the time the user hovers the Undo button the objects it names may have changed. baseVersion lets a persisted or rebased entry be checked against the current document, and selectionBefore and selectionAfter hold ids, never DOM nodes, so entries stay plain data.
mergeKey is how coalescing knows that two edits belong together. Keystrokes in the same card title share a key such as type:card-42:title, so typing in a different card always starts a new step, even within 500 ms.
| State | Lives in | Why there |
|---|---|---|
| The document | Document store (normalized by id) or a CRDT document | Shared by every view; synced to the server |
| History stacks | History manager, one per open document, in memory | Session-scoped; not part of the document |
| Selection and viewport | Editor UI state | Restored by history, never recorded as steps |
| Pending outside effects | Effects queue, with a timer per action | Must be cancellable until the window closes |
| Unsent ops | Sync outbox, optionally mirrored to IndexedDB | Must survive a flaky network, and a reload if the editor is offline-first |
The API every editor uses
interface History {
// Recording
push(entry: { label: string; redo: Op[]; undo: Op[]; mergeKey?: string }): void;
begin(label: string): void; // open a transaction (a gesture)
commit(): void; // close it as one entry
cancel(): void; // roll it back, record nothing
group<T>(label: string, fn: () => T): T; // begin + fn + commit, or cancel on throw
checkpoint(): void; // the next push never merges
// Moving
undo(): HistoryEntry | null;
redo(): HistoryEntry | null;
clear(): void; // on document close or reset
// Reading, for buttons, menus, and tests
canUndo(): boolean;
canRedo(): boolean;
undoLabel(): string | null; // "Move card"
redoLabel(): string | null;
subscribe(listener: () => void): () => void;
}
group covers commands like "Align 12 shapes"; begin, commit, and cancel cover gestures; subscribe lets React read it with useSyncExternalStore.Each editor creates its history with an adapter: createHistory({ apply, rebase, stillApplies, limits }). The canvas adapter applies Immer patches to its store and rebases text ops; the form builder's adapter only applies patches, because it has no shared text. That's the whole reuse story: three editors, one module, three small adapters.
Plugging into Redux and Zustand
Both libraries already have a pattern for wrapping state updates, and that's where recording belongs. In Redux, a middleware (or Redux Toolkit's Immer-powered reducers with patches enabled) can capture patches for actions marked undoable. In Zustand, a store middleware wraps set.
Ready-made libraries exist: redux-undo wraps a reducer with past, present, and future snapshots, and zundo adds a temporal middleware to Zustand. Both are snapshot-based and single-user, which makes them a good fit for the form builder alone and a poor fit for the shared canvas.
import { create } from "zustand";
import { enablePatches, produceWithPatches } from "immer";
enablePatches();
// Inside a Zustand store: every recorded update goes through `edit`.
const useBoard = create<BoardState>()((set, get) => ({
doc: initialDoc,
edit(label: string, recipe: (draft: Doc) => void) {
const [next, redo, undo] = produceWithPatches(get().doc, recipe);
if (redo.length === 0) return; // nothing changed: no entry
set({ doc: next });
history.push({ label, redo, undo });
outbox.send(redo);
},
}));
// A component:
useBoard.getState().edit("Move card", (d) => {
d.cards["42"].columnId = "done";
});
edit function is the only door for undoable changes, so nothing reaches the document without an entry, and nothing gets an entry without being sent.Check your understanding · Exercise 11
A developer adds "Align 12 shapes" by calling edit 12 times in a loop, once per shape. What goes wrong, and what fixes it? Select all that apply.
Testing, performance, and observability
Undo bugs are silent. A wrong inverse doesn't throw; it leaves the document a little different from before, and the user finds out days later. So the most important tests aren't examples but properties that must hold for every sequence of edits.
Property tests: do, then undo, returns the same document
A property test generates hundreds of random inputs and checks a rule for each one. Libraries like fast-check generate random sequences of edits, run them, and when a rule fails, shrink the sequence to the smallest one that still fails, which is usually two or three steps that point straight at the bug.
import fc from "fast-check";
const anEdit = fc.oneof(
fc.record({ kind: fc.constant("move"), id: fc.constantFrom("a", "b", "c"), x: fc.integer() }),
fc.record({ kind: fc.constant("rename"), id: fc.constantFrom("a", "b", "c"), name: fc.string() }),
fc.record({ kind: fc.constant("delete"), id: fc.constantFrom("a", "b", "c") }),
);
test("undoing every edit restores the start", () => {
fc.assert(fc.property(fc.array(anEdit, { maxLength: 50 }), (edits) => {
const editor = createTestEditor(seedDoc);
for (const e of edits) editor.run(e);
while (editor.history.canUndo()) editor.history.undo();
expect(editor.doc()).toEqual(seedDoc);
while (editor.history.canRedo()) editor.history.redo();
expect(editor.doc()).toEqual(runWithoutHistory(seedDoc, edits));
}));
});
- Grouping rules, with fake timers: keystrokes 100 ms apart merge, a 600 ms pause splits, a space starts a new step,
cancel()records nothing. - Limits: after 250 pushes there are 200 entries, and the oldest are the ones gone; a single entry over the byte budget is still kept.
- Multiplayer convergence: two simulated clients make random edits and random undos, exchange ops in random order, and must end with identical documents in which neither user's surviving edits were reverted by the other's undo.
- Keyboard routing, in an end-to-end test:
Cmd+Zinside a focused input changes the input, not the board; outside it, the board changes and the live region says what was undone.
Making undo feel instant
Our target was under 50 ms from key press to paint. Most steps touch a few objects and take well under a millisecond to apply. The slow cases are big: undoing a delete of 500 shapes re-inserts 500 objects. Three rules keep those fast.
- Apply all of an entry's ops in one store update and notify subscribers once. 500 separate updates mean 500 re-renders; one update means one.
- Subscribe narrowly. Components select their own object by id, so undoing a move of card 42 re-renders card 42 and its column, not the whole board. Normalized data makes that possible.
- Keep held keys cheap. A held
Cmd+Zrepeats about 10 to 30 times a second. Each undo must finish well inside a frame, and announcements wait for a quiet moment, as the keyboard chapter described.
What to measure in production
- Undo and redo counts per session, and the labels most often undone: a feature that gets undone a lot is often a confusing feature.
- Skipped or failed undos with their reason (deleted by someone else, rejected by the server, changed by someone else). A rising rate means the conflict policy needs work.
- History size at the 95th percentile, in entries and estimated bytes, and how often the byte budget drops entries.
- Time to apply an undo, as a
performance.measure, with the entry's op count.
Check your understanding · Exercise 12
A property test fails, and fast-check shrinks it to: delete shape a, rename shape a, undo, undo. What's the most likely bug? Select all that apply.
Running it in the interview
Undo is a feature question, so the interviewer expects depth on a few mechanisms rather than a tour of a whole app. In 45 minutes you won't cover everything in this guide. Pick the parts that show judgment, and say out loud what you're leaving out.
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 5 | Scope | Ask: shared or single user? What is one step? Anything with outside effects? Document size? Write the answers down. |
| 5 to 12 | The model | Snapshots versus operations, with the memory numbers for 5,000 shapes. Pick operations with inverses; mention Immer patches. |
| 12 to 18 | The stacks and grouping | Draw past, future, and the document. Show a drag as one transaction and typing coalesced at 500 ms. |
| 18 to 28 | Multiplayer and the server | Local undo only. Undo is a normal edit through the outbox. Stale inverses and how they're rebased. What happens on rejection. |
| 28 to 36 | Architecture and API | Draw the module, the adapter, the store, and the outbox. Write the History interface. |
| 36 to 45 | Keys, accessibility, limits, testing | Platform shortcuts, native fields, live region, the two limits, the property test. |
A strong opening sounds like this: "Before I draw anything: is this document shared, and do edits save to a server? If yes, I'll store operations with inverses rather than snapshots, undo only the local user's steps, and treat every undo as a normal edit to the server. Let me start with the stacks and then show how a drag becomes one step." In three sentences you've shown that you know the two hardest constraints and have a plan for the next 40 minutes.
Interviewers for a hard feature question often push on one area until you reach the edge of what you know. Expect them to pick the multiplayer case or the server rejection case. Stay concrete: name the card, the two users, the positions, and what each client shows at each moment. A worked example beats a general claim every time.
Follow-up questions to expect
Think first
"How is this different from version history?"
Undo is a session-scoped, per-user stack of small inverse steps, in memory, for the last few minutes of work. Version history is a server feature: periodic and named snapshots of the whole document, for everyone, kept for months.
They complement each other. Because version history exists, undo can safely discard branches and drop old entries, and doesn't need to survive reloads.
Think first
"The user goes offline, makes ten edits, undoes three, and reconnects. What happens?"
Nothing special. The outbox holds the ten edits and the three undo ops in order, and the outbox can cancel pairs that never left (an edit and its undo). On reconnect it sends the rest with their base version, and remote ops that arrived meanwhile rebase the remaining history entries.
The interesting case is a rejection after reconnect: those ops roll back and their entries are dropped, with a message explaining what couldn't be saved.
Think first
"A plugin runs a script that makes 3,000 changes. How should undo treat it?"
As one step, opened by the platform around the plugin run with group("Run plugin: Rename layers"), so the user can reverse the whole thing with one press.
It may be a large entry, so it counts against the byte budget and may push old entries out. If the plugin also calls outside services, those effects aren't in history, and the plugin API should say so.
Common mistakes
- Starting with
structuredClonesnapshots and never revisiting them once the document size and collaborators come up. - Recording every
pointermoveor keystroke as a step. - Treating undo as local-only, so the next reload brings the change back.
- Global undo in a shared document, or stale inverses that overwrite a teammate's work.
- Taking over
Cmd+Zinside native text fields. - Putting outside effects such as emails on the undo stack.
- No limit on history, or a limit on entries only.
Check your understanding · Exercise 13
You have 5 minutes left and haven't covered multiplayer. What's the best use of the time? Select all that apply.
Summary
- Store operations with their inverses, not snapshots: about 300 bytes per step instead of 1.5 MB, sendable to the server, and adjustable when teammates edit. Let Immer's
produceWithPatchesgenerate inverses instead of hand-writing them. - History is past and future stacks around the live document. A new local edit clears redo; selection, view changes, and remote edits never do. Discard branches, and let server-side version history be the safety net.
- One step per intention: explicit transactions for gestures and commands, 500 ms coalescing with word boundaries for typing, and a cancel that records nothing.
- Only document state is undoable. Selection is restored with steps, never a step. Outside effects get a delayed commit with an undo toast, or an honestly named compensating action.
- Bound history by entries and by bytes, drop the oldest, store references instead of payloads, and keep entries as plain data so nothing leaks.
- Undo is a normal edit: inverse ops go through the same ordered outbox, the server can reject them, and rejected entries are dropped with a message. Optimistic rollbacks are never history entries.
- In multiplayer, undo only your own steps, and keep them valid: rebase positions against remote ops (or use CRDT ids), and skip what someone else has deleted or changed since.
- Route keys by platform and focus: native fields keep their own undo until they commit, the editor owns undo elsewhere, buttons name the step, and a live region announces it.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- Immer: Patches
produceWithPatches, inverse patches,applyPatches, and the opt-inenablePatches(). - Redux: Implementing Undo History The past, present, and future shape for snapshot-based undo in a reducer.
- Yjs: Y.UndoManager A selective undo manager: tracked origins,
captureTimeout,stopCapturing, and metadata such as cursor positions on stack items. - How Figma's multiplayer technology works Figma's engineering post, including its principle for undo and redo in a shared file.
- ProseMirror reference: prosemirror-history A production editor history with
depthandnewGroupDelay(defaults 100 and 500 ms), andcloseHistoryfor forcing a new step. - CodeMirror reference: history CodeMirror 6's history, with
minDepthandnewGroupDelay(defaults 100 and 500 ms). - MDN: InputEvent.inputType The list of input types, including
historyUndoandhistoryRedo. - MDN: Element beforeinput event Where
beforeinputfires, and why some of its events can't be cancelled. - MDN: Document.execCommand() Deprecated, but its edits stay on the browser's native undo stack.
- W3C: Input Events Level 2 The specification for
beforeinputand its input types. - WAI-ARIA Authoring Practices: Toolbar pattern Keyboard behavior for a toolbar of Undo, Redo, and other buttons.
- fast-check documentation Property-based testing for JavaScript and TypeScript, with shrinking.
Concepts behind it
- Operational transformation (OT)A way to merge edits made at the same time: each incoming edit is rewritten, or transformed, to account for the edits it missed, so every copy ends with the same text. Google Docs is built on it.
- Conflict resolution strategiesWhat to do when two people change the same data: from last write wins per field, to version checks with
If-Match, to three-way merges and merge screens, chosen separately for each kind of data. - Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Screen readers and live regionsHow screen readers turn a page into speech or braille, how every control gets its accessible name and description, and how to announce changes like search results, saves, and errors with live regions.
- State machines for UIModel a flow as a fixed set of states and the events that move between them, so impossible combinations like "loading and failed at once" can't happen and every button does the right thing in every state.
- IndexedDBThe browser's built-in transactional database: object stores of structured values, indexes for queries, versioned migrations, and room for hundreds of megabytes, which makes it the place for offline data, drafts, and large blobs.
- Normalizing stateStoring each entity once, in a table keyed by its id, and having lists and relationships point at ids instead of holding copies, so one change updates every view that shows it.