Back to React
React
medium
senior

How would you implement undo and redo functionality in a React based drawing or form 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.

7 min read·~20 min to think through

Two architectures, picked based on state size.

Model 1: snapshot stacks (the default).

tsx
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:

ts
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.

  1. 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.
  1. Cap history depth. past.slice(-100) on push. Otherwise long sessions OOM.
  1. Cursor-position restoration. In editors, undo/redo should restore selection too. Store { state, selection } together.
  1. 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.
  • immer patches — produceWithPatches returns 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.

Related questions