Skip to main content

Imperative Commands

useUndoableState covers the common single-value case. For mutations that touch a list, graph, canvas, or any data structure the hook can't own directly, push commands imperatively:

import { useAmnesia } from "react-amnesia";

type Item = { id: string; text: string };

export function AddItemButton({ list }: { list: { add(item: Item): void; remove(id: string): void } }) {
const { push } = useAmnesia();
return (
<button
onClick={() => {
const item: Item = { id: crypto.randomUUID(), text: "New item" };
list.add(item);
push(
{
label: "Add item",
redo: () => list.add(item),
undo: () => list.remove(item.id),
},
{ applied: true },
);
}}
>
Add
</button>
);
}

The Command shape

FieldRequiredPurpose
redo(signal)yesApply (or re-apply) the action. Runs on every redo and on initial push when do is absent.
undo(signal)yesRevert the action. Runs on every undo.
do(signal)noOne-shot initial-apply handler. When present, replaces redo for the first push. Useful when first-apply mints state (an id) that subsequent replays must reuse.
labelnoHuman-readable label for history UIs.
coalesceKeynoMerge identity for coalescing.
coalesceWindowMsnoPer-command coalescing window override. undefined uses scope default, Number.POSITIVE_INFINITY removes the time bound, and <= 0 disables coalescing for that push.
metanoFree-form data for tooling. Pass through metaTransform to redact secrets.

All three handlers receive an AbortSignal — see the Async Commands guide.

applied: true vs default

push(command) calls command.redo() (or command.do()) once on insertion. If your call site already mutated state itself — for example, the user-event handler called list.add(item) directly before pushing — pass { applied: true } to skip the initial invocation.

list.add(item); // mutate first
push({ redo, undo }, { applied: true }); // record the inverse without re-running redo

Retroactive refine with amend(...)

amend(patch) updates the most recent past entry in place.

  • Targets only past[past.length - 1]
  • Keeps existing fields unless overridden
  • Keeps original undo by default
  • Clears the redo stack, same as push
const { push, amend } = useAmnesia();

// initial edit
setTitle("a");
await push(
{
label: "Edit title",
redo: () => setTitle("a"),
undo: () => setTitle(""),
},
{ applied: true },
);

// later refinement should not add another undo stop
setTitle("ab");
await amend({
label: "Edit title (refined)",
redo: () => setTitle("ab"),
// omit undo to preserve pre-edit restoration
});

When to prefer useUndoableState

If the mutation IS just "set this value to something else", reach for the hook instead:

const [text, setText] = useUndoableState("");

Imperative push is right when:

  • The mutation touches data the hook doesn't own (lists, graphs, canvas)
  • The inverse depends on a value computed at the call site (like item.id)
  • Multiple steps must collapse into one entry → see Transactions

See also