Undo/Redo
2026-08-09 · 7 min read
Undo/redo looks simple until you implement it. The naive approach — store a copy of state on every change, rewind on Ctrl+Z — breaks immediately: too much memory, too-fine granularity, wrong behavior in collaborative contexts. Getting it right requires decisions about what to snapshot, when to snapshot it, how to store it efficiently, and whose history to track.
The stack model
Undo/redo is two stacks. The undo stack holds past states; the redo stack holds states that were undone.
undoStack: [state_0, state_1, state_2] ← state_2 is current
redoStack: []
After Ctrl+Z:
undoStack: [state_0, state_1]
redoStack: [state_2]
current: state_1
After Ctrl+Z again:
undoStack: [state_0]
redoStack: [state_2, state_1]
current: state_0
After Ctrl+Y (redo):
undoStack: [state_0, state_1]
redoStack: [state_2]
current: state_1
Any new change after an undo clears the redo stack — you've branched off the timeline, so the undone states are no longer reachable.
function applyChange(newState: EditorState) {
undoStack.push(currentState);
redoStack = []; // branch point — redo history is gone
currentState = newState;
}
function undo() {
if (!undoStack.length) return;
redoStack.push(currentState);
currentState = undoStack.pop();
}
function redo() {
if (!redoStack.length) return;
undoStack.push(currentState);
currentState = redoStack.pop();
}
Snapshots vs. inverse operations
There are two ways to represent a history entry.
Snapshot-based — store the full state at each checkpoint. Undo means restoring the previous snapshot. Simple to implement, works for any state shape, but memory cost scales with state size and history depth.
Inverse operation-based — store the operation and its inverse. Undo means applying the inverse. insertText("hello", at: 5) is undone by deleteText(5, 10). Memory cost is proportional to the operation, not the document size — which matters for large documents. But every operation needs a correct inverse, and compound operations need compound inverses.
Most editors use snapshots for simplicity and mitigate the memory cost with structural sharing (covered below). Inverse operations are more common in collaborative editors where you need per-user history and the operation log already exists for operational transform or CRDT purposes. Operational Transform (OT) and Conflict-free Replicated Data Types (CRDTs) are the two dominant algorithms for merging concurrent edits from multiple users — both rely on a log of discrete operations, which doubles as the raw material for inverse-based undo.
Granularity
Character-by-character undo is the wrong granularity. Typing "hello" and pressing Ctrl+Z five times to delete it letter by letter is not what users expect — they expect the whole word or phrase to undo as a unit.
Time-based batching — changes within a threshold (typically 500ms) of each other merge into one undo entry. Continuous typing becomes one entry; a pause starts a new one.
const BATCH_THRESHOLD_MS = 500;
let lastChangeTime = 0;
function recordChange(newState: EditorState) {
const now = Date.now();
if (now - lastChangeTime < BATCH_THRESHOLD_MS) {
// Merge into current entry — update the top of the undo stack in place
undoStack[undoStack.length - 1] = currentState;
} else {
undoStack.push(currentState);
redoStack = [];
}
lastChangeTime = now;
currentState = newState;
}
Explicit transaction boundaries — wrapping changes in a transaction makes the entire transaction one undo entry regardless of how many individual operations it contains. In Lexical, every editor.update() call is one undoable action. A paste that inserts 500 words is one undo entry, not 500.
Semantic grouping — some operations are always a single entry regardless of time or transaction: paste, drag-and-drop, find-and-replace, format all selected text. These are user-perceived as atomic actions, so they undo atomically.
In practice, editors combine all three: explicit transactions set the floor, time-based batching merges rapid consecutive transactions, and semantic rules override both for specific operations.
Structural sharing
Storing a full deep clone of the document on every change is expensive. A document with thousands of nodes copied hundreds of times into the undo stack hits memory limits quickly.
Structural sharing avoids the copies. When a node changes, only the nodes on the path from the root to the changed node are new objects — everything else is shared between snapshots.
Before change (bold "world"): After change:
RootNode (v1) RootNode (v2) ← new
└── ParagraphNode (v1) └── ParagraphNode (v2) ← new
├── TextNode("Hello ", v1) ├── TextNode("Hello ", v1) ← shared
└── TextNode("world", v1) └── TextNode("world", v2) ← new (bold=true)
The undo stack holds references to RootNode (v1) and RootNode (v2). The TextNode("Hello ") is shared between both — it's the same object in memory. Only three new objects were created for a document with four nodes.
This is the same technique used by immutable data structure libraries like Immer and Immutable.js, and it's why editors like Lexical treat EditorState as immutable — mutations always produce new node objects rather than modifying existing ones, which makes structural sharing automatic.
Memory cost per history entry becomes proportional to the depth of the changed path, not the document size.
User-scoped undo in collaborative editing
In a solo editor, Ctrl+Z means "undo the last change to this document." In a collaborative editor, that definition breaks: if Alice types "hello" and Bob types "world", Alice pressing Ctrl+Z should undo "hello" — not "world", even if "world" was more recent in the document's history.
User-scoped undo tracks each user's operations independently and inverts only theirs, leaving collaborators' changes in place.
Document timeline (newest first):
Bob: insert "world" at 6
Alice: insert "hello" at 0
Alice presses Ctrl+Z:
Apply inverse of Alice's last op: delete "hello" at 0
Bob's "world" is unaffected → now at position 0
The inverse operation must be transformed against all intermediate operations from other users before being applied — the same operational transform (OT) machinery used to apply collaborative edits in the first place. If Alice's inverse was "delete at position 0" but Bob has since inserted text before position 0, the inverse needs to be shifted to account for Bob's change before it can be applied correctly.
This is why user-scoped undo is only practical in editors that already have an OT or CRDT layer. The undo operation is just another operation in the collaborative log, attributed to the user who triggered it.
In a plugin architecture
In editors like Lexical, undo/redo is a plugin. The core doesn't manage history — the HistoryPlugin does.
<LexicalComposer initialConfig={config}>
<RichTextPlugin />
<HistoryPlugin delay={500} /> {/* time-based batching threshold */}
</LexicalComposer>
The plugin listens to editor state changes, manages the undo/redo stacks, and registers handlers for UNDO_COMMAND and REDO_COMMAND. The core just emits state updates and accepts commands — it has no knowledge of history.
This separation matters: a read-only viewer of the same document can mount the editor core without the HistoryPlugin and get no history overhead at all.
Where this appears
Rich text editors — every text editor ships undo/redo. The interesting design questions are granularity (how do you batch "hello world" into one entry vs. two?) and whether history survives a page reload (some editors persist the undo stack to localStorage).
Drawing tools (Figma, Excalidraw) — operations are geometric transforms, node additions, and deletions rather than text inserts. Structural sharing matters here too: a canvas with thousands of elements shouldn't clone all of them on every move.
Spreadsheets — formula changes, cell edits, and formatting are all undoable. The undo stack often has a bounded depth (Excel defaults to 100 entries) to cap memory use.
Collaborative editors (Google Docs) — user-scoped undo is the hard part. The document history and the undo history are the same log, just filtered by author when the user presses Ctrl+Z.