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
| Field | Required | Purpose |
|---|---|---|
redo(signal) | yes | Apply (or re-apply) the action. Runs on every redo and on initial push when do is absent. |
undo(signal) | yes | Revert the action. Runs on every undo. |
do(signal) | no | One-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. |
label | no | Human-readable label for history UIs. |
coalesceKey | no | Merge identity for coalescing. |
coalesceWindowMs | no | Per-command coalescing window override. undefined uses scope default, Number.POSITIVE_INFINITY removes the time bound, and <= 0 disables coalescing for that push. |
meta | no | Free-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
undoby 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
- Recipe: Imperative List Mutation
- Transactions guide — for batched compound mutations
- Async Commands guide