Recipes

Undo / redo

Rindle has no undo API — and doesn't need one. Because a mutation is just `(name, args)` and replays deterministically, undo is a plain command stack of inverse mutations: each entry is a `do`/`undo` pair of ordinary mutator calls. No snapshots, no view diffing — undo is just another optimistic write that rebases.

View as Markdown

Undo is usually where a data layer gets complicated — snapshot the state, diff it, restore it, and hope nothing else moved. Rindle skips all of that. A mutation is (name, args) and replays deterministically, so undo is the inverse mutation — another ordinary write. Keep a stack of do/undo pairs; undo pops one and calls its undo. The engine applies it optimistically, rebases it, and syncs it like any write, and because it’s a write it’s itself redoable.

The pattern: a command stack of inverse mutations

Each command is two mutator calls — the forward action and its inverse, computed from the value before the edit:

type Command = { label: string; do: () => void; undo: () => void };

const stack: Command[] = [];
let cursor = 0;

function run(cmd: Command) {
  cmd.do();                 // an ordinary optimistic mutate — applies now, syncs, rebases
  stack.length = cursor;    // a fresh action drops any redo tail
  stack.push(cmd);
  cursor = stack.length;
}

export const undo = () => { if (cursor > 0) stack[--cursor].undo(); };
export const redo = () => { if (cursor < stack.length) stack[cursor++].do(); };

At the callsite, capture the prior value and express undo as the same mutator with that value:

function rename(id: string, next: string, prev: string) {
  run({
    label: "Rename",
    do:   () => app.mutate.renameIssue({ id, title: next }),
    undo: () => app.mutate.renameIssue({ id, title: prev }),
  });
}

That’s the whole mechanism. Some inverses are the opposite mutator rather than the same one with an old value — undo of addIssue is removeIssue, undo of a move is a move back:

run({
  label: "Add issue",
  do:   () => app.mutate.addIssue({ id, title }),
  undo: () => app.mutate.removeIssue({ id }),
});

Why this is enough

Because undo replays through the same mutator path as any write, everything you already get from the engine comes for free:

  • It rebases. An undo issued while other writes are in flight is reconciled like any optimistic write — you never restore a stale snapshot over newer state.
  • It syncs. Undo isn’t a local-only rewind; the inverse mutation goes to the server, so a co-editor sees the undo too.
  • It’s redoable. Redo is just re-running the command’s do. No separate machinery.
  • No view bookkeeping. You never snapshot or diff a materialized view — you record two closures over a couple of ids and values.

The one rule is the same one that makes mutators replayable: a command’s do/undo must be pure functions of their captured args — capture the values at record time, not a reference to live component state.

In the wild — Strut

Strut is a slide-deck editor on Rindle with deep undo across every edit. Its history is exactly this: a bounded stack of { label, undo, redo } commands, framework-agnostic so it can be driven from anywhere:

// src/editor/history.ts
export interface Command {
  label: string
  undo: () => void
  redo: () => void
}

export class History {
  private undoStack: Command[] = []
  private redoStack: Command[] = []
  // …push / undo / redo / a monotonic `revision` for async guards
}

src/editor/history.ts L11–29 · Strut

Every editing gesture records a command whose undo is the same mutator with the prior value — here, changing a shape’s fill:

// src/editor/Inspector.tsx
const setFill = (fill: string) => {
  const before = c.fill ?? '3498db'
  if (before === fill) return
  mutate.setShapeFill({ id: c.id, fill })
  history.push({
    label: 'Fill',
    redo: () => mutate.setShapeFill({ id: c.id, fill }),
    undo: () => mutate.setShapeFill({ id: c.id, fill: before }),
  })
}

src/editor/Inspector.tsx L136–145 · Strut

Some inverses are the opposite mutator — undo of an insert is removeComponent (Header.tsx L204–212), undo of a reorder is a reorder back (SlideWell.tsx L182–190). A small provider binds the stack to ⌘/Ctrl-Z (and ⇧-Z / Y for redo):

src/editor/UndoProvider.tsx L69–100 · Strut

See also

  • Isomorphic mutators — why a mutation replays deterministically (the property undo leans on).
  • Folded mutations — the folded form is the live preview; the plain form is the committed write your undo/redo replays.
  • The browser client — how each mutation applies optimistically and rebases.