Foundations

Undo and redo

Taking back what was done and doing it again: one line of history, an action at a time.

Markdown

Undo takes back what was just done; redo does it again. Together they make an interface safe to try things in: nothing done by a slip of the hand has to stay done.

Best practices

Undo an action, not a keystroke. One undo takes back everything one action did — a whole paste, a whole clearing, every cell a fill reached — however many values it changed. A run of small changes that people think of as one, such as the characters of a word or the steps of a drag, is one action too.

Keep one line of history. Doing something new after undoing discards what was undone. Redo repeats only what undo has just taken back.

Show what changed. After an undo or a redo, bring what it changed into view and select it, and say that it happened to people who cannot see it happen.

Undo only what is still there. When the data is replaced from elsewhere — other records are loaded, a collaborator's version arrives — changes made to what was there before no longer apply, and are forgotten rather than undone into something else.

Leave a field's own undo to the field. While text is being typed in a field, undo takes back the typing, as it does in any field. The wider history begins where the field is committed.

Keep enough, not everything. Hold a generous number of actions and let the oldest go.

Conventions

IntentKeyboard
UndoCommand-Z
RedoCommand-Shift-Z, and Command-Y as well

Command is ⌘ on Apple platforms and Ctrl elsewhere.

Behaviour

Undoing and redoing

Undo returns what was done, last first; redo returns it again, in the order it was done
const history = createHistory<string>();
history.push("a");
history.push("b");
expect(history.undo()).toBe("b");
expect(history.undo()).toBe("a");
expect(history.redo()).toBe("a");
expect(history.redo()).toBe("b");
With nothing to undo, or to redo, there is nothing returned
const history = createHistory<string>();
expect(history.canUndo()).toBe(false);
expect(history.undo()).toBeUndefined();
history.push("a");
expect(history.canUndo()).toBe(true);
expect(history.canRedo()).toBe(false);
expect(history.redo()).toBeUndefined();
history.undo();
expect(history.canRedo()).toBe(true);
Doing something new after undoing discards what was undone
const history = createHistory<string>();
history.push("a");
history.push("b");
history.undo();
history.push("c");
expect(history.redo()).toBeUndefined();
expect(history.undo()).toBe("c");
expect(history.undo()).toBe("a");
It behaves as a plain list of what was done would, whatever is done to it
type Action = { kind: "push"; value: number } | { kind: "undo" | "redo" | "clear" };
const action = fc.oneof(
  fc.integer().map((value): Action => ({ kind: "push", value })),
  fc.constantFrom<Action>({ kind: "undo" }, { kind: "redo" }, { kind: "clear" }),
);
fc.assert(
  fc.property(fc.integer({ min: 1, max: 5 }), fc.array(action, { maxLength: 60 }), (limit, actions) => {
    const history = createHistory<number>(limit);
    let model: number[] = [];
    let done = 0;
    for (const step of actions) {
      if (step.kind === "push") {
        model = [...model.slice(0, done), step.value].slice(-limit);
        done = model.length;
        history.push(step.value);
      } else if (step.kind === "undo") {
        const expected = done > 0 ? model[done - 1] : undefined;
        if (done > 0) done -= 1;
        if (history.undo() !== expected) return false;
      } else if (step.kind === "redo") {
        const expected = done < model.length ? model[done] : undefined;
        if (done < model.length) done += 1;
        if (history.redo() !== expected) return false;
      } else {
        model = [];
        done = 0;
        history.clear();
      }
      if (history.canUndo() !== done > 0 || history.canRedo() !== done < model.length) return false;
    }
    return true;
  }),
);

Limit

Past its limit, the oldest entry is forgotten and the rest are kept
const history = createHistory<number>(3);
for (let value = 1; value <= 5; value += 1) history.push(value);
expect([history.undo(), history.undo(), history.undo(), history.undo()]).toEqual([5, 4, 3, undefined]);
A history that keeps nothing is refused
expect(() => createHistory(0)).toThrow(RangeError);

Merging

A change merged into the one before is undone with it, as one
const history = createHistory<string>();
history.push("typed");
history.merge("typed", longer);
history.merge("typed", longer);
expect(history.undo()).toBe("typed++");
expect(history.undo()).toBeUndefined();
A change that does not merge is recorded separately
const history = createHistory<string>();
history.push("pasted");
history.merge("typed", longer);
expect(history.undo()).toBe("typed");
expect(history.undo()).toBe("pasted");
Nothing merges into a change that has been undone, nor into an empty history
const history = createHistory<string>();
history.merge("typed", longer);
expect(history.undo()).toBe("typed");
history.merge("typed", longer);
expect(history.undo()).toBe("typed");
expect(history.canUndo()).toBe(false);
// Nor into the one before an undone change: that would keep what the new change discards.
history.push("typed");
history.push("typed");
history.undo();
history.merge("typed", longer);
expect([history.undo(), history.undo(), history.canRedo()]).toEqual(["typed", "typed", true]);

React

Install

npx shadcn@latest add https://ui.publictheta.com/r/history.json
Source

src/primitives/history.ts

/**
 * What was done, kept so that it can be undone and done again.
 *
 * An entry is whatever its owner needs to reverse a change and to repeat it — a
 * list of cells with their values before and after, say. This keeps the entries
 * and the place among them; applying one is the owner's work.
 *
 * - Undo returns the last entry done and steps back; redo returns the entry
 *   stepped back from and steps forward.
 * - Doing something new after undoing discards what was undone: there is one
 *   line of history, not a tree.
 * - Only so many entries are kept. Past the limit the oldest is forgotten, at no
 *   cost to the rest: the entries are a ring, and every operation is constant.
 * - A run of small changes — typing, dragging — can be merged into the entry
 *   before it, so that one undo takes back the whole run.
 */
export interface History<T> {
  /** Record a change that has been made. */
  readonly push: (entry: T) => void;
  /**
   * Record a change as part of the one before: `merge` is given the last entry
   * and returns the two as one, or `null` to record this one separately.
   */
  readonly merge: (entry: T, merge: (last: T) => T | null) => void;
  /** The entry to reverse, or `undefined` when there is nothing to undo. */
  readonly undo: () => T | undefined;
  /** The entry to repeat, or `undefined` when there is nothing to redo. */
  readonly redo: () => T | undefined;
  readonly clear: () => void;
  readonly canUndo: () => boolean;
  readonly canRedo: () => boolean;
}

export function createHistory<T>(limit = 200): History<T> {
  if (!Number.isInteger(limit) || limit < 1) throw new RangeError("A history keeps at least one entry.");
  const entries: Array<T | undefined> = Array.from({ length: limit });
  // The ring holds `length` entries from `start`; the first `done` of them are done, the rest undone.
  let start = 0;
  let length = 0;
  let done = 0;

  const at = (index: number) => (start + index) % limit;

  function push(entry: T): void {
    // What was undone is forgotten, and let go of.
    for (let index = done; index < length; index += 1) entries[at(index)] = undefined;
    length = done;
    if (length === limit) {
      start = at(1);
      length -= 1;
    }
    entries[at(length)] = entry;
    length += 1;
    done = length;
  }

  return {
    push,
    merge(entry, merge) {
      const merged = done > 0 && done === length ? merge(entries[at(done - 1)] as T) : null;
      if (merged === null) push(entry);
      else entries[at(done - 1)] = merged;
    },
    undo() {
      if (done === 0) return undefined;
      done -= 1;
      return entries[at(done)];
    },
    redo() {
      if (done === length) return undefined;
      done += 1;
      return entries[at(done - 1)];
    },
    clear() {
      entries.fill(undefined);
      start = 0;
      length = 0;
      done = 0;
    },
    canUndo: () => done > 0,
    canRedo: () => done < length,
  };
}