Skip to main content

Transactions

A transaction wraps multiple pushes into a single composite entry. A single Ctrl+Z reverses the whole bundle. Throws inside the work function trigger automatic rollback.

import { useAmnesia } from "react-amnesia";

export function ApplyPresetButton({ doc }: { doc: DocStore }) {
const { transaction, pending } = useAmnesia();

const apply = async () => {
const before = doc.snapshot();
await transaction("Apply preset", async (tx) => {
await tx.push({
redo: () => doc.setTitle("Untitled (preset)"),
undo: () => doc.setTitle(before.title),
});
await tx.push({
redo: () => doc.addTag("preset"),
undo: () => doc.removeTag("preset"),
});
await tx.push({
redo: () => doc.setUpdatedAt(Date.now()),
undo: () => doc.setUpdatedAt(before.updatedAt),
});
});
};

return (
<button disabled={pending} onClick={apply}>
Apply preset
</button>
);
}

Behaviour

SituationWhat happens
Work resolvesAll buffered redo/undo pairs collapse into ONE composite entry on the past stack.
Work throws / rejectsEvery buffered undo runs in reverse, the work's error is re-thrown to the caller, no entry is committed.
clear() runs mid-awaitBuffered undos run, onError({ phase: "stale" }) fires, transaction resolves to null.
Empty work (no tx.push)Resolves to null, no entry committed.
tx.label("…") calledOverrides the composite's label. Last write wins.
Nested transaction(...) inside workFlattens into the outer. Nested label is ignored. Returns null.
Composite undoRuns all buffered undos in reverse order.
Composite redoRuns all buffered redos in original order.

tx.push vs store.push

Inside the work function, use tx.push — it appends to the transaction's buffer. A bare store.push (or useAmnesia().push) from inside work hits busy and is dropped silently from the user's perspective. Easy mistake to make; the anti-pattern doc has a section on it.

Async work + AbortSignal

The work function receives an AbortSignal as its second argument:

await transaction(async (tx, signal) => {
await tx.push({
redo: async () => fetch("/api/save", { method: "POST", signal }).then((r) => r.json()),
undo: () => undefined,
});
});

When clear() aborts the signal, the work can either honor it (rejects silently with no onError) or ignore it (epoch drop, phase: "stale").

Coalescing

Composite entries never coalesce with stack neighbors. Inside a transaction, individual tx.push calls also do not coalesce with each other — each is appended to the buffer verbatim.

See also