Implement undo/redo in a React app
Two viable models: (1) Snapshot stacks — store past/present/future state snapshots; on undo, pop past → present and push old present to future. Cheap with structurally-shared state (Immer). (2) Command pattern — store reversible operations (`apply`/`invert`). Better for huge state (drawings) where snapshots are expensive. Coalesce rapid changes (typing) into one history entry; cap history depth.
Two architectures, picked based on state size.
Model 1: snapshot stacks (the default).
type History<T> = { past: T[]; present: T; future: T[] };
function useUndoRedo<T>(initial: T) {
const [state, dispatch] = useReducer(
(s: History<T>, action: { type: string; payload?: T }) => {
switch (action.type) {
case "SET":
if (Object.is(action.payload, s.present)) return s;
return { past: [...s.past, s.present], present: action.payload!, future: [] };
case "UNDO":
if (s.past.length === 0) return s;
return {
past: s.past.slice(0, -1),
present: s.past[s.past.length - 1],
future: [s.present, ...s.future],
};
case "REDO":
if (s.future.length === 0) return s;
return {
past: [...s.past, s.present],
present: s.future[0],
future: s.future.slice(1),
};
default:
return s;
}
},
{ past: [], present: initial, future: [] }
);
return {
state: state.present,
set: (v: T) => dispatch({ type: "SET", payload: v }),
undo: () => dispatch({ type: "UNDO" }),
redo: () => dispatch({ type: "REDO" }),
canUndo: state.past.length > 0,
canRedo: state.future.length > 0,
};
}That's a full undo/redo machine in 30 lines. Works for forms, configuration UIs, anything with bounded state.
Use Immer for big state. produce(draft => ...) returns a structurally-shared immutable update. Each "snapshot" only retains the diff, not the whole object — so a 100k-node tree with one change costs ~O(depth) memory, not O(size).
Model 2: command pattern (for drawings, editors).
When the state is huge (every pixel of a canvas, every line of a code editor), storing full snapshots wastes memory. Instead, store operations:
type Command = {
apply: (s: State) => State;
invert: (s: State) => State; // or store enough info to compute the inverse
};
const history: Command[] = [];
let cursor = 0;
function execute(cmd: Command) {
state = cmd.apply(state);
history.splice(cursor); // drop future on new action
history.push(cmd);
cursor = history.length;
}
function undo() {
if (cursor === 0) return;
cursor--;
state = history[cursor].invert(state);
}
function redo() {
if (cursor === history.length) return;
state = history[cursor].apply(state);
cursor++;
}Each command captures only the delta — "draw stroke from (x,y) to (x',y') with color X". The inverse is "remove stroke id N". Storage is proportional to user actions, not state size.
The four senior details.
- Coalesce typing into one entry. If you push a snapshot per keystroke, undo unwinds one character at a time — frustrating. Debounce: push to history after 500ms of inactivity, OR detect "continuing operations" (typing in same field) and merge.
- Cap history depth.
past.slice(-100)on push. Otherwise long sessions OOM.
- Cursor-position restoration. In editors, undo/redo should restore selection too. Store
{ state, selection }together.
- Persistence. If users expect undo across page reload, serialize the history to localStorage / IndexedDB. Command pattern persists smaller; snapshots persist faster on restore.
Collaborative undo. In multi-user apps (Figma, Google Docs), undo gets harder: undoing your edit shouldn't undo someone else's edit that came after. Operational Transform (OT) and CRDTs handle this — each user has a per-user history of their own operations, transformed against intervening remote ops. Out of scope for most interviews unless they explicitly ask.
Library options.
use-undoable— generic snapshot hook for React.zundo— undo middleware for Zustand.immerpatches —produceWithPatchesreturns inverse patches automatically; perfect for command-pattern.
Keyboard wiring. Ctrl/Cmd+Z for undo, Ctrl+Y / Cmd+Shift+Z for redo. Hook a global listener but only intercept when focus isn't in a contenteditable / native input — native fields have their own undo stack you shouldn't shadow.
Follow-up questions
- •Snapshot vs command-pattern — when does each win?
- •How do you coalesce rapid typing into a single undo entry?
- •How would you implement undo/redo in a collaborative editor?
- •Why use Immer for snapshot-based history?
Common mistakes
- •Pushing a snapshot per keystroke — undo becomes painful.
- •Never capping history — memory grows unbounded.
- •Forgetting to clear the future stack on new action — redo brings back wrong state.
- •Intercepting Ctrl+Z globally even when focus is in a native input.
Performance considerations
- •Immer's structural sharing keeps snapshot memory bounded.
- •Don't deep-clone on every change — use immutable updates or Immer.
- •For very large states (canvas pixels), prefer command pattern.
Edge cases
- •Undo across async actions (an API call finished after undo) — decide whether undo cancels the request.
- •Closing the tab mid-history — persist or accept loss.
- •Multiple users editing — local undo must not revert remote changes.
Real-world examples
- •Figma — command pattern, with multi-user OT for collaborative undo.
- •VSCode — coalesced typing entries, per-document history.
- •Photoshop — snapshot for the canvas, command for vector layers.