Quick Start
Wrap the part of your tree that should share an undo stack in
AmnesiaProvider, drop an AmnesiaShortcuts somewhere inside it for
keyboard bindings, then use useUndoableState for any value the user can
edit.
import { AmnesiaProvider, AmnesiaShortcuts, useUndoableState } from "react-amnesia";
function TitleEditor() {
const [title, setTitle] = useUndoableState("Untitled", {
label: "Edit title",
coalesceKey: "edit:title",
});
return <input value={title} onChange={(event) => setTitle(event.target.value)} />;
}
export default function App() {
return (
<AmnesiaProvider capacity={200}>
<AmnesiaShortcuts />
<TitleEditor />
</AmnesiaProvider>
);
}
Ctrl+Z / Cmd+Z undoes the last edit; Ctrl+Shift+Z / Cmd+Shift+Z /
Ctrl+Y redoes. Rapid keystrokes that share a coalesceKey collapse into a
single history entry, so a single undo reverts the entire burst.
What just happened
AmnesiaProviderset up an in-memory history store for everything inside it.useUndoableState("Untitled", { label, coalesceKey })returned a[value, set, reset]tuple. Callingset(...)updates the React state AND pushes a new history entry; the entry'sredore-applies the new value and itsundorestores the previous one.<AmnesiaShortcuts />mounted akeydownlistener onwindow. It routes Ctrl+Z to the active scope (here just the default scope) and ignores chords originating from native editable elements (text-like<input>types, plus<textarea>,<select>, andcontenteditable) so the browser's native input undo keeps working.coalesceKey: "edit:title"makes consecutive keystrokes within a few hundred milliseconds merge into one entry. A single Ctrl+Z reverts the whole burst rather than each character.
Imperative commands
For actions that don't fit a single value (lists, graphs, transforms), push commands directly:
import { useAmnesia } from "react-amnesia";
function AddItemButton({ list }: { list: { add(item: Item): void; remove(id: string): void } }) {
const { push } = useAmnesia();
return (
<button
onClick={() => {
const item = createItem();
list.add(item);
push(
{
label: "Add item",
redo: () => list.add(item),
undo: () => list.remove(item.id),
},
{ applied: true },
);
}}
>
Add
</button>
);
}
push(command) calls command.redo() once on insertion. Pass
{ applied: true } when the call site has already mutated state itself.
Persistence
When paired with react-mnemonic, usePersistedUndoableState reads and
writes the value through useMnemonicKey while still recording each user
edit on the local Amnesia stack:
import { MnemonicProvider } from "react-mnemonic";
import { AmnesiaProvider, AmnesiaShortcuts } from "react-amnesia";
import { usePersistedUndoableState } from "react-amnesia/mnemonic";
function ThemePicker() {
const { value, set } = usePersistedUndoableState<"light" | "dark">("theme", {
defaultValue: "light",
label: "Change theme",
});
return (
<select value={value} onChange={(e) => set(e.target.value as "light" | "dark")}>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
);
}
export default function App() {
return (
<MnemonicProvider namespace="my-app">
<AmnesiaProvider>
<AmnesiaShortcuts />
<ThemePicker />
</AmnesiaProvider>
</MnemonicProvider>
);
}
The undo stack itself is intentionally not persisted. Closures aren't serializable, and replaying old commands against new state is usually the wrong default. Reloads keep the latest value, but the history starts fresh on each session.
Where to go next
- Keyboard Shortcuts guide
- Coalescing guide
- Imperative Commands guide
- Multi-Scope Routing guide — for apps with several authoring surfaces (canvas + property panel etc.)
- Async Commands guide
- Transactions guide
- AI Docs — canonical invariants for agent-assisted code