Skip to main content

Forms

A form is the most common case where multiple useUndoableState hooks need to share one undo stack. The good news: they share by default. Every hook bound to the same scope (the implicit "default" scope unless you specify otherwise) pushes onto the same ordered history. One Ctrl+Z reverts the most recent edit regardless of which hook produced it.

This guide covers the patterns specific to forms: per-field coalescing, atomic resets, post-submit cleanup, and when to use a separate scope.

The simplest form

import { AmnesiaProvider, AmnesiaShortcuts, useUndoableState } from "react-amnesia";

function ContactForm() {
const [name, setName] = useUndoableState("", { coalesceKey: "form:name" });
const [email, setEmail] = useUndoableState("", { coalesceKey: "form:email" });

return (
<form>
<input value={name} onChange={(e) => setName(e.target.value)} />
<input value={email} onChange={(e) => setEmail(e.target.value)} />
</form>
);
}

export default function App() {
return (
<AmnesiaProvider>
<AmnesiaShortcuts />
<ContactForm />
</AmnesiaProvider>
);
}

Both fields push onto the default scope. Type "scott" in name, then "foo" in email, then Ctrl+Z three times: email goes back from "foo" to "" (one entry), name goes back from "scott" to "" (one entry — the keystrokes coalesced).

Field-level coalesceKey

Without coalesceKey, every keystroke is its own undo entry. Typing "scott" produces five entries; the user has to press Ctrl+Z five times.

With per-field coalesceKey, consecutive keystrokes within coalesceWindowMs (default 400ms) merge into one entry. The keys must be distinct per field so a typing burst on one field doesn't weirdly merge with the next field.

useUndoableState("", { coalesceKey: "form:contact:name" });
useUndoableState("", { coalesceKey: "form:contact:email" });
useUndoableState("", { coalesceKey: "form:contact:phone" });

Namespace prefix (form:contact:...) keeps the keys unique even when the same form is mounted next to other undoable surfaces. Don't use the user-facing label here; labels can change but the coalesce key should be stable across a single edit burst.

What goes in useUndoableState vs plain useState

StateHookWhy
User-authored field valuesuseUndoableStateThe thing the user typed is what they want to undo.
Validation errors / "is this field valid"useStateDerived. Recomputed from values on every render.
"Submit in flight" / loadinguseStateEphemeral; meaningless after the request settles.
currentStep in a wizarduseStateNavigation isn't an edit; Ctrl+Z shouldn't move pages.
Server response cacheuseState or your data layerNot user input; not undoable.
"Has the user touched this field" / dirty flagsuseStateSame logic — a function of value comparison, not a separate edit.

The general rule: undo the value, not its derivatives. Validation, dirty flags, and submit state all recompute from values on render.

Atomic reset via transaction

If a "Reset" button calls each setName(initial) etc. in sequence, the user gets N undo entries — one per field. They have to press Ctrl+Z N times to get back to the pre-reset state.

Wrap the reset in a transaction so the whole bundle collapses into a single composite entry:

import { useAmnesia } from "react-amnesia";

function ContactForm({ initial }: { initial: { name: string; email: string } }) {
const [name, setName] = useUndoableState(initial.name, { coalesceKey: "form:name" });
const [email, setEmail] = useUndoableState(initial.email, { coalesceKey: "form:email" });
const { transaction } = useAmnesia();

const reset = () =>
transaction("Reset form", async (tx) => {
const previous = { name, email };
await tx.push({
redo: () => {
setName(initial.name);
setEmail(initial.email);
},
undo: () => {
setName(previous.name);
setEmail(previous.email);
},
});
});

return (
<form>
{/* inputs */}
<button type="button" onClick={() => void reset()}>
Reset
</button>
</form>
);
}

A single Ctrl+Z restores every field to its pre-reset values.

Why not just call each hook's reset() (the third tuple slot)? Because useUndoableState's reset is not undoable — it wipes the bound scope's history. That's correct for a "discard changes" button (where you don't want pre-reset state to be recoverable) but wrong for a "reset to defaults" button (where you do).

Post-submit cleanup

After a successful submit, history of pre-submit edits is no longer meaningful — the server has accepted the values and the user shouldn't "undo" their way back to a draft they already submitted.

const { clear } = useAmnesia();

const submit = async () => {
await api.save({ name, email });
clear();
};

clear() drops both stacks of the bound scope. The form's current values stay (the React state is owned by the hooks), but Ctrl+Z is now a no-op until the user makes a new edit. This is what you want for a "submitted draft" UX.

When to use a separate scope

If the form is one part of a larger app that has its own undo (a canvas, a layer tree, etc.), put the form in its own scope. Otherwise Ctrl+Z while focused on the canvas would also see the form's recent keystrokes, and vice versa.

import { AmnesiaProvider, useAmnesiaFocusClaim, useUndoableState } from "react-amnesia";

function ContactForm() {
const claim = useAmnesiaFocusClaim("form:contact");
const [name, setName] = useUndoableState("", {
scopeId: "form:contact",
coalesceKey: "name",
});
const [email, setEmail] = useUndoableState("", {
scopeId: "form:contact",
coalesceKey: "email",
});

return (
<section tabIndex={-1} {...claim}>
<input value={name} onChange={(e) => setName(e.target.value)} />
<input value={email} onChange={(e) => setEmail(e.target.value)} />
</section>
);
}

The pattern:

  • Every field pins to scopeId: "form:contact".
  • A useAmnesiaFocusClaim("form:contact") on the form's outer container routes Ctrl+Z to that scope while focus is inside the form.
  • When focus leaves the form, the active scope falls back to default and Ctrl+Z affects whatever else the app is doing.
  • Inside a single named scope, the field coalesceKeys no longer need the form prefix — they only have to be unique within the scope.

Common mistakes

Sharing one coalesceKey across all fields. Don't:

useUndoableState("", { coalesceKey: "form" });
useUndoableState("", { coalesceKey: "form" });

A keystroke burst on field A followed quickly by a keystroke burst on field B would coalesce together, producing a confused entry where Ctrl+Z half-reverts both fields. Always use distinct keys per field.

Putting validation errors in useUndoableState. Validation is derived from values. Recompute it on render:

const [email, setEmail] = useUndoableState("", { coalesceKey: "form:email" });
const emailError = validateEmail(email); // ← plain derivation, no hook

Forgetting to clear after submit. Without clear(), the user can Ctrl+Z back into a state that no longer matches what's on the server. Either clear() or remount the provider with a new key.

Calling useUndoableState's reset() for "reset to defaults" when the user expects the reset itself to be undoable. That tuple-slot reset wipes the scope's history; use a transaction wrapper instead.

See also