Anti-Patterns
These patterns often "work" at runtime but still encode the wrong undo contract.
Persisting The Undo Stack
The history stack is in-memory only. Closures cannot be serialized and old commands are usually wrong against fresh application state.
Wrong:
- serializing
useAmnesia().pastand rehydrating it on the next session - writing every
push()tolocalStorageso reloads "remember" undo
Prefer:
- persisting the underlying value via
react-mnemonic(or any other store) - letting the history start empty on each reload — that is the documented contract
Replaying Stale Closures Across Document Switches
Switching documents leaves entries on the stack whose closures reference the prior document's data.
Wrong:
- relying on accumulated history when the user opens a different document
- expecting Ctrl+Z to "undo across documents"
Prefer:
- calling
clear()after switching state - scoping providers per document with a
keyprop so the provider remounts cleanly
Pushing During Render
Pushing inside a render path produces unbounded history and React warnings.
Wrong:
- calling
push(...)directly in a component body - calling
push(...)insideuseMemoor a derived selector
Prefer:
- calling
push(...)from event handlers, effects, or commands - using
useUndoableState(...)for the common single-value path so the hook owns the call site
Capturing Stale React State Inside Closures
React state captured by a closure goes stale across re-renders.
Wrong:
const [value, setValue] = useState(initial);
push({
redo: () => setValue("next"),
undo: () => setValue(value), // ← `value` is stale on later renders
});
Prefer:
- capturing the previous value at the call site as a local constant
- using a ref kept in sync with state, then reading
ref.currentin the closure - using
useUndoableState(...), which already does this correctly
Treating Capacity-Bounded History As An Audit Log
react-amnesia silently drops the oldest entries when capacity is reached.
Wrong:
- expecting the stack to retain every action ever performed
- using history snapshots as evidence of what the user did
Prefer:
- a separate, append-only log for compliance or analytics
- treating the undo stack as a UX affordance only
Expecting Closed Shadow Roots To Be Inspected
<AmnesiaShortcuts skipEditableTargets={true} /> walks
event.composedPath() to detect editables across shadow boundaries.
Closed shadow roots (element.attachShadow({ mode: "closed" })) are
deliberately opaque: composedPath does not enter them, and the
browser's design intent is that their internals stay hidden from outside
listeners.
Wrong:
- expecting an
<input>inside amode: "closed"shadow root to be recognized as editable - working around this by patching
attachShadowor stealing the closed root reference
Prefer:
- use
mode: "open"on shadow roots that should cooperate with app-level shortcuts - if the host author shipped
mode: "closed"and you can't change it, expose an explicittargetelement on the host or have the host callevent.preventDefault()on chords it handles itself
Stealing The Browser's Native Undo Inside Inputs
The browser ships its own undo for <input>, <textarea>, and
contenteditable. Stealing it usually breaks user expectations.
Wrong:
<AmnesiaShortcuts skipEditableTargets={false} />while the app contains regular form fields- pushing undo entries on every keystroke and then preventing default for native chord behavior
Prefer:
- the default
skipEditableTargets: true useUndoableState(...)only for fields that genuinely benefit from app-level undo, with acoalesceKey
Using clear() As A Substitute For undo()
clear() discards both stacks. It cannot be undone.
Wrong:
- calling
clear()to "undo" a single bad action - calling
clear()because the redo stack feels noisy
Prefer:
undo()for individual reversalsclear()only on document switches, route transitions, logout, or similar resets
Expecting Branching On New Pushes
After an undo(), a new push() clears the future stack. There is no branch
recovery in v0.
Wrong:
- expecting "undo, edit, redo" to restore the previously redone state
- relying on
futureentries surviving a push
Prefer:
- treating new edits after an undo as a destructive operation against the redo stack
- prompting the user before discarding the future stack if your UX requires that
Putting Secrets Into Command meta
History snapshots are exposed to descendant components and devtools.
Wrong:
- access tokens, refresh tokens, or session IDs in
meta - raw user PII in command labels rendered into the DOM
Prefer:
- references (e.g. user id) and resolving sensitive data at runtime
- structured labels that summarize the action without leaking material
Sharing coalesceKey Across Form Fields
Multiple useUndoableState hooks in the same scope already share one
undo stack. They do not need to share coalesceKey — that controls
how consecutive pushes merge, not how the stack is shared.
Wrong:
const [name, setName] = useUndoableState("", { coalesceKey: "form" });
const [email, setEmail] = useUndoableState("", { coalesceKey: "form" });
A keystroke burst on name followed quickly by a burst on email
would coalesce into one entry across both fields. Ctrl+Z would
half-revert one and leave the other partially mutated.
Prefer:
const [name, setName] = useUndoableState("", { coalesceKey: "form:contact:name" });
const [email, setEmail] = useUndoableState("", { coalesceKey: "form:contact:email" });
Distinct coalesceKey per field. The shared stack is automatic via the
shared scope.
Putting Validation State In useUndoableState
Validation errors, "is this field valid", "has this been touched",
"submit in flight" — none of these should be in useUndoableState.
They're derived from values or ephemeral session state.
Wrong:
const [email, setEmail] = useUndoableState("");
const [emailError, setEmailError] = useUndoableState<string | null>(null);
Now Ctrl+Z can revert the validation error independently of the value that produced it. Confusing UX, and the validation error is recomputable from the value anyway.
Prefer:
const [email, setEmail] = useUndoableState("", { coalesceKey: "form:email" });
const emailError = validateEmail(email); // ← plain derivation on render
Same principle for "submit in flight" (useState — it's session state)
and "current step" in a wizard (useState — Ctrl+Z shouldn't navigate).
Forgetting To clear() After Submit
If the form's pre-submit history stays on the stack after the user has clicked Save and the server has accepted the values, Ctrl+Z lets them "undo" their way back into a draft state that no longer matches the server.
Wrong:
const submit = async () => {
await api.save({ name, email });
// history still contains every keystroke up to submit
};
Prefer:
const { clear } = useAmnesia();
const submit = async () => {
await api.save({ name, email });
clear();
};
Or remount the provider with a key if you want a fully fresh form
instance for the next entry.
Treating useUndoableState's reset As Scope-Local
reset clears the entire scope the hook is bound to — not just the
value owned by this hook. Sibling useUndoableState calls and imperative
useAmnesia(scopeId).push(...) entries in the same scope are wiped along
with it.
Wrong:
[a, setA, resetA] = useUndoableState(0)paired with another[b, setB] = useUndoableState(0)in the same component, expectingresetA()to leaveb's history intact- mounting a "discard draft" reset on a default-scope hook in an app that also tracks unrelated reversible actions in the default scope
Prefer:
- pin sensitive history to its own scope:
useUndoableState(0, { scopeId: "draft" }) - use
resetonly when the scope-wide wipe is actually what you want - if you need to "reset only this value" without touching history, write
the inverse as a normal
set(initial)— but then the operation is itself undoable, and the user can roll back the reset
Leaving DevTools Enabled In Production
enableDevTools exposes the provider's full inspection api on
window.__REACT_AMNESIA_DEVTOOLS__, including triggers that mutate state
and a dump() that returns every scope's meta. Production users do not
need this surface, and it can leak data that metaTransform is supposed
to redact in development if your metaTransform is dev-only.
Wrong:
<AmnesiaProvider enableDevTools>shipped to production unconditionally- conditioning on
process.env.NODE_ENVinside the component (still bundled in production output if your bundler doesn't dead-code-eliminate)
Prefer:
- gate via a build-time env flag the bundler can statically eliminate:
enableDevTools={import.meta.env.DEV}(Vite),enableDevTools={process.env.NODE_ENV !== "production"}(with bundler define), or a custom feature flag wired to a known constant - ensure your
metaTransformruns in production too, so even an accidentally-enabled devtools surface gets redacted state
Driving UI From Lifecycle Hooks
Lifecycle hooks (onPush / onAmend / onUndo / onRedo / onClear) are for
side-channel observers — analytics, devtools, audit logs. They are NOT a
substitute for subscribing to the snapshot.
Wrong:
- using
onPushto update React state viasetStatefrom outside the React tree - treating the hook payload as the source of truth for "what's on the stack"
Prefer:
useAmnesia()(or the scoped variant) — drives UI through normal subscriber semanticsonPushfor fire-and-forget telemetry that does not feed back into the rendered output
Putting Side-Effecting Mutations Inside metaTransform
metaTransform runs every time the snapshot is built and every time a hook
fires. If it has side effects, they fire repeatedly with surprising timing.
Wrong:
metaTransform: (meta) => {
if (meta.audit) sendAuditLog(meta); // ← runs N times per mutation
return meta;
};
Prefer:
- pure transforms only (
return { ...meta, secret: undefined }etc.) - emit telemetry from
onPush/onAmend/onUndo/onRedoinstead, which fire exactly once per logical action
Calling store.push From Inside Transaction work
The store is single-flight while a transaction is in flight. A bare
store.push(...) from inside the work function hits busy and is dropped
silently from the user's perspective.
Wrong:
await transaction("preset", async (tx) => {
await tx.push({ redo, undo });
// BAD — second mutation is lost.
await store.push({ redo, undo });
});
Prefer:
- always use
tx.push(...)inside the work function so the mutation joins the buffer - if you really need a "do this on its own outside the transaction" effect, schedule it after the transaction resolves
Holding tx Outside The Work Function
The TransactionApi handle is closed when the surrounding transaction(...)
call resolves. Calls to tx.push / tx.label after that point throw.
Wrong:
let captured;
await transaction((tx) => {
captured = tx;
});
await captured.push(...); // throws
Prefer:
- treat
txas a borrow whose lifetime is the work function's call frame - start a fresh transaction for the next batch of mutations
Modeling Recoverable Errors As Transaction Throws
A throw inside work rolls back every buffered undo. If only some of
the work failed, you may be undoing successful steps too.
Wrong:
await transaction(async (tx) => {
await tx.push(saveMetadata); // succeeded
try {
await tx.push(uploadAvatar); // failed
} catch {
// swallow — but tx is already aware of the failure
throw new Error("avatar failed");
}
});
Prefer:
- decide up-front whether each step is part of the atomic bundle
- if a step is genuinely optional, fan it out as a separate
pushafter the transaction commits, with its own retry / undo semantics
Routing useUndoableState Through The Active Scope
useUndoableState always pins to a stable scopeId. It does not — and
should not — float to the active claim. React state is owned by a component
instance; the history surface it belongs to is a stable property, not a
focus-driven one.
Wrong:
- attempting to make
useUndoableState"scope-aware" by readinguseAmnesia().scopeIdinside the call site and passing it asscopeId - expecting
useUndoableStateto migrate its entries when focus moves
Prefer:
- declare
scopeIdonce at the call site as a literal:useUndoableState(initial, { scopeId: "canvas" }) - use
useAmnesia()(no arg) only for read-only views (toolbar buttons, badges) that should follow the active claim
Mixing useAmnesiaFocusClaim With Inert DOM
useAmnesiaFocusClaim returns capture-phase handlers. They only fire when
focus or pointer-down events actually reach the element they're attached to.
Wrong:
- spreading the handlers onto a
<div>that has notabIndexand no focusable descendants — focus never enters, so the claim never fires - attaching them inside a child but expecting the parent's events to bubble through (capture-phase only catches at the bound element)
Prefer:
- attach the handlers to a focusable container (
tabIndex={-1}is fine for programmatic focus,tabIndex={0}for tab navigation) - ensure the container has at least one focusable descendant or accepts pointer-down itself
Using Command.do When redo Alone Would Suffice
do exists for the narrow case where first-apply and replay genuinely need
different closures. Using it gratuitously means the command has two code
paths to keep in sync.
Wrong:
doandredoare identical literal copies of each otherdodiffers fromredoonly by adding "first time!" telemetry that could ride ononPush(Workstream E) when that lands
Prefer:
- omit
doentirely; letredorun on initial push - use
doonly when first-apply produces a value (e.g. an id) that subsequent replays must reuse, or when first-apply has a side effect that replay must not
Capturing Mutable State In do Without A Stable Closure
do runs once. redo runs many times. If redo reads from a variable that
do populated, that variable must outlive both — typically a closed-over
let or a ref.
Wrong:
push({
do: () => {
const id = mintId();
list.add({ id, text });
},
// `id` does not exist here.
redo: () => list.restore(id),
undo: () => list.remove(id),
});
Prefer:
- declare the captured value at the call-site scope so
doandredo/undoshare it - treat the entry as a self-contained unit: any state
redo/undoneeds must be captured at push time, never recomputed inside the closures
Ignoring The AbortSignal On Long-Running Async Handlers
The signal arrives as the only argument to every command handler (and as
the second argument to a transaction's work). Async handlers that pass
it to fetch or check signal.aborted in long loops cancel cleanly when
clear() / dispose() runs. Handlers that ignore it run to completion
unnecessarily and end up firing onError({ phase: "stale" }) when their
result is dropped.
Wrong:
push({
redo: async () => {
await api.applyTheme(next); // ignores cancellation
},
undo: async () => api.applyTheme(current),
});
Prefer:
push({
redo: async (signal) => {
await api.applyTheme(next, { signal });
},
undo: async (signal) => api.applyTheme(current, { signal }),
});
The handler that honors the signal completes silently when cancelled (no
onError, no log noise). The one that ignores it still drops the commit
but produces a phase: "stale" event in your error handler.
Awaiting push Inside Render
Calling await store.push(...) inside a render function, useMemo, or any
synchronous render-phase code suspends the render and produces an unbounded
stream of pushes.
Wrong:
await push(...)inside a component bodyawait push(...)inside auseMemofactory
Prefer:
void push(...)from event handlers when you don't care about the resolutionawait push(...)from event handlers anduseEffectcallbacks when you do- Capture the pending state from
useAmnesia().pendingto drive UI feedback
Stacking Async Pushes Without Awaiting
The store is single-flight. A second push while another is pending resolves
to null and fires onError({ phase: "busy" }) — the user's action is
dropped, not queued.
Wrong:
- Firing two un-awaited async pushes back-to-back from a button handler
- Assuming pending pushes will run sequentially after the first resolves
Prefer:
awaiteach push and let the user retry if a second click was intended- Disable the trigger UI while
useAmnesia().pending === true - Compose multi-step work into one composite command rather than several
Throwing From onError
The onError handler is invoked from inside the store. A throw is caught and
discarded so the store stays consistent.
Wrong:
- relying on a thrown
onErrorto surface failures to React error boundaries - side effects inside
onErrorthat themselves can throw and are not guarded
Prefer:
- forwarding to your error tracker explicitly
- guarding side effects with
try { ... } catch { /* ignore */ }inside the handler
Inventing Local Package Shims
Do not "fix" missing type information by shadowing the package.
Wrong:
react-amnesia.d.tsdeclare module "react-amnesia"- importing from unpublished internal paths
Prefer:
importandimport typefromreact-amnesia,react-amnesia/core,react-amnesia/mnemonic, orreact-amnesia/native- checking
src/index.ts,package.json, and the API docs before assuming a surface is missing
Treating The Provider As Optional
useAmnesia(...) and useUndoableState(...) are not global singleton hooks.
Wrong:
- calling them outside an
AmnesiaProvider - assuming a global window-level fallback
Prefer:
- one explicit provider per undo scope (often per document or workspace)
useAmnesiaScopeOptional()only for reusable components that should silently degrade outside a provider