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.