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

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](/docs/mutators) 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:

```ts
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:

```ts
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:

```ts
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](/docs/mutators): 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](https://github.com/tantaman/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:

```ts
// 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](https://github.com/tantaman/strut/blob/2e7660edd157c14a0587f3eba553051295072e8a/src/editor/history.ts#L11-L29) · Strut*

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

```ts
// 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](https://github.com/tantaman/strut/blob/2e7660edd157c14a0587f3eba553051295072e8a/src/editor/Inspector.tsx#L136-L145) · Strut*

Some inverses are the opposite mutator — undo of an insert is `removeComponent`
([`Header.tsx` L204–212](https://github.com/tantaman/strut/blob/2e7660edd157c14a0587f3eba553051295072e8a/src/editor/Header.tsx#L204-L212)), undo of a reorder is a reorder back
([`SlideWell.tsx` L182–190](https://github.com/tantaman/strut/blob/2e7660edd157c14a0587f3eba553051295072e8a/src/editor/SlideWell.tsx#L182-L190)). A small provider binds
the stack to ⌘/Ctrl-Z (and ⇧-Z / Y for redo):

*[`src/editor/UndoProvider.tsx` L69–100](https://github.com/tantaman/strut/blob/2e7660edd157c14a0587f3eba553051295072e8a/src/editor/UndoProvider.tsx#L69-L100) · Strut*

## See also

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

---

[View this page on Rindle](https://rindle.sh/docs/undo-redo)
