Skip to main content

Decision Matrix

Use these tables when the code is "almost obvious" but one wrong undo choice would change user behavior after a Ctrl+Z.

Single Scope vs Multi-Scope

NeedApproach
Whole-app undo on a single documentOne <AmnesiaProvider>, default scope only — no scopeId anywhere needed
Authoring app with several long-lived surfaces (canvas, property panel, etc.)One <AmnesiaProvider> with named scopes; useAmnesiaFocusClaim per surface
Multiple documents open in tabs, each with its own historyOne <AmnesiaProvider key={documentId}> per document; remount on switch
Undoable component library distributed independentlyDefault scope is fine; consumer apps wrap in their own provider
Modal / overlay that should temporarily steal Ctrl+ZMount its content with useAmnesiaFocusClaim("modal"); release on close

Form With Multiple Fields

NeedApproach
Several fields share one undo stack (default expectation)Multiple useUndoableState calls in the same scope. They share automatically — no scopeId coordination needed.
Avoid one entry per keystrokePer-field coalesceKey: "form:<formname>:<fieldname>" — distinct per field
"Reset" button that itself is undoableWrap each field's reset in a transaction; the bundle becomes one composite
"Discard changes" that is NOT undoableCall useUndoableState's third tuple slot (the reset from the hook) — it wipes scope history
"Submit" should retire the pre-submit historyuseAmnesia().clear() after a successful submit
Form embedded in an app with other undoable surfacesPin every field to a named scope (scopeId: "form:contact") and use useAmnesiaFocusClaim on the outer container
Validation errors / submit-in-flight / current wizard stepuseState, NOT useUndoableState — they're derived or ephemeral

Pin to a Scope vs Track the Active Scope

NeedHook
Component is logically tied to one surface (canvas toolbar, props breadcrumb)useAmnesia("canvas") — pinned
Component reflects "whatever the user is editing right now"useAmnesia() — tracks active
useUndoableState for a value that lives in a component{ scopeId: "..." } — always pinned
Keyboard shortcut binding for the whole window<AmnesiaShortcuts /> — tracks active
Region-scoped shortcut binding (canvas keyboard ops only)<AmnesiaShortcuts scopeId="canvas" target={canvasRef.current} />
Reading the active scope id for breadcrumbsuseAmnesiaScopes().activeScopeId

useUndoableState vs push vs useAmnesia

NeedAPIWhy
Single reversible value, replaces a useStateuseUndoableState(initial, opts)Smallest call site; the hook owns redo and undo closures
Mutating something the hook can't own (lists, graphs, canvas)useAmnesia().push({ redo, undo })Full control over the inverse; pass { applied: true } after mutate
Reading the stack for UI (history list, breadcrumb, badges)useAmnesia() snapshotAlready memo-stable; no need to subscribe manually
Menu/toolbar labels + enablement onlyuseAmnesiaLabels(scopeId?)Selector snapshot avoids re-renders when derived label fields stay the same
Direct programmatic undo / redo (toolbar buttons, menu items)useAmnesia().undo() / .redo()Resolves to the affected entry id, or null when the stack was empty

DevTools Registry vs Lifecycle Hooks vs Subscribers

NeedApproach
External tool / browser extension reads live state<AmnesiaProvider enableDevTools devToolsId="…">
AI agent introspects history without touching app codeDevTools registry — resolve(id) then call api
Drive undo / redo from a debugging UIDevTools triggerUndo / triggerRedo
Telemetry on every push / amend / undo / redo / clearProvider hooks (onPush, onAmend, onUndo, onRedo, onClear)
React component renders stateuseAmnesia() snapshot — subscribers, not hooks
Per-scope analyticsPer-scope hook in scopes={{ canvas: { onPush } }}
Forward errorsonError
Redact secrets / PII before they leave the storemetaTransform
Need synchronous side effect at mutation timeNOT a hook — wrap the mutation; hooks are post-notify

Lifecycle Hooks vs Subscribers

NeedApproach
Telemetry (analytics, audit log)Provider-level onPush / onAmend / onUndo / onRedo / onClear
Driving UI state ("how many entries on the stack?")useAmnesia() snapshot — subscribers, not hooks
Per-scope analytics with different fields per surfacePer-scope override in scopes={{ canvas: { onPush } }}
Forward errors to a trackeronError (existing) — not a lifecycle hook
Redact secrets / PII before they leave the storemetaTransform
Need to fire side effects synchronously with the mutationNOT a hook — wrap the mutation; hooks are post-notify

Reset / Discard / Remove

NeedAPI
"Discard changes" button (snap back to a stable starting value)useUndoableState's reset()
"Load preset" or "load template" (set a specific value, drop history)useUndoableState's reset(preset)
Lazily compute the discard-target value at click timeuseUndoableState's reset(() => compute())
Whole-scope reset triggered from elsewhere (toolbar button, route change)useAmnesiaScopes().clear(scopeId)
Clear every scope (document switch, logout)useAmnesiaScopes().clear() (no arg)
Restore persisted defaultValue and wipe historyusePersistedUndoableState().reset()
Set a specific persisted value and wipe historyusePersistedUndoableState().reset(value)
Delete persisted key entirely (next read = defaultValue) and wipe historyusePersistedUndoableState().remove()

transaction vs Many pushes

NeedApproach
One user action mutates several places; one Ctrl+Z should undo all of themuseAmnesia().transaction("Apply preset", ...)
Each mutation should remain individually undoableSeveral push(...) calls
Multi-step async work (call API, then update UI, then write to disk) atomictransaction(async (tx) => { ... })
Handlers might fail; want all-or-nothingtransaction — rollback runs on throw
Want a "dry-run, then commit if happy" patterntransaction and throw to abort
Just one mutationPlain push — transaction would only add notify-pair overhead

Command.do vs redo-only

SituationRecommendation
First-apply and re-apply share identical closures (the common case)Omit do; rely on redo only
First-apply mutates state in-place; re-apply restores by reference (e.g. inserting a freshly-created node vs. re-inserting it after undo)Define both do and redo
Caller already mutated state and just wants to record the inversepush(cmd, { applied: true }); do is skipped
Need different telemetry on first-apply vs replaydo for the original, redo for replays
Want to coalesce a burst into one entrycoalesceKey; each push's do runs at its own push time, the merged entry stores the latest redo

Cancellation Strategy

ScenarioWhat you do
Handler does an HTTP callfetch(url, { signal }) — auto-cancels on clear() / dispose()
Handler runs a long synchronous loopif (signal.aborted) return (or throw) at the top of each iteration
Handler is sync and fastIgnore the signal — it can't abort before the function returns
You want a clean cancellation (no error log)Throw an AbortError-shaped error after observing signal.aborted
You want the existing stale-drop behavior with an onError eventIgnore the signal entirely; the epoch check still drops the commit
Transaction needs to cancel a multi-step network workflowPass the work-fn's signal to every fetch and to nested commands
Two ops should share cancellationWrap them in one transaction — the work-fn's signal covers both

Sync vs Async Command Handlers

NeedRecommendation
Mutating local component state (the common case)Sync redo / undo; subscribers see one notify per mutation
Calling a server before committing (theme apply, server URL change)Async redo / undo; subscribers see pending: true during await
useUndoableState setterStays sync-feeling — internal handlers are sync, no pending window
Need to coalesce rapid burstsSync handlers — coalescing across async commands is fragile
Mid-command another push arrivesSecond call resolves to null, fires onError({ phase: "busy" })
clear() runs while an async command is awaitingCommand resolves to null, fires onError({ phase: "stale" })
In-flight async command's own redo rejectsPromise rejects to caller; onError({ phase: "push" }); entry not added
undo / redo handler throws (sync or async)Resolves to null; entry stays in place; onError({ phase: "undo" | "redo", recoverable: true })

coalesceKey vs Separate Entries

SituationRecommendationWhy
Each keystroke in a text fieldShared coalesceKey: "edit:<field>"A single Ctrl+Z reverts the whole burst
Slider drag updating a value 60 times per secondShared coalesceKey: "drag:<control>"Otherwise capacity is consumed by intermediate frames
Discrete clicks (Add item, Delete item)No coalesceKeyEach action should be reversible on its own
Distinct fields edited in alternationDifferent coalesceKey per fieldCoalescing is keyed; bursts on field A do not absorb field B
Pause longer than coalesceWindowMs between keystrokesSame coalesceKey but separate entriesTime-based gap signals a logical pause

Capacity Choice

Use caseRecommended capacity
Casual UI (preferences, toggles)Default (100)
Document editor with frequent typing and undo bursts3001000
Canvas / drawing tool with high-frequency commands1000+, but rely on coalesceKey
Audit log or compliance trailNot appropriate — model separately

Persisting Undoable State

NeedChooseResult after reload
Reversible only within a sessionuseUndoableState(...)Value resets, history starts empty
Value should survive reload, history may resetusePersistedUndoableState(...)Value persists via react-mnemonic, history empty
Value persisted, but writes should not push undo entriesuseMnemonicKey(...) directlyPersistence-only path, no undo
Reversible bulk action that touches multiple persisted keysuseAmnesia().push(...) + manual useMnemonicKeyCaller controls the inverse for each persisted key

clear() vs undo() All The Way Down

NeedRecommendation
Undo recent edit onlyundo()
Undo to a known earlier checkpointLoop undo() while canUndo
Document switch, route change, "open new file"clear() after switching state
User pressed "Discard changes"clear() after restoring saved state
Logoutclear(); closures may capture user-scoped data

Keyboard Binding Surface

NeedRecommendation
App-wide undo / redoOne <AmnesiaShortcuts /> inside the provider
Modal that owns its own undo<AmnesiaShortcuts enabled={false} /> while the modal is open
Custom Vim-style chord (e.g. u and Ctrl+R)Skip <AmnesiaShortcuts />; call useAmnesia().undo() from your handler
Surface-scoped undo (canvas region only)<AmnesiaShortcuts target={canvasRef.current} skipEditableTargets={false} />
Native <input> undo should keep workingDefault — skipEditableTargets is true

Error Reporting Choice

NeedConfigure
Default behavior (log via console.error)Omit onError
Forward to error tracker (Sentry, Datadog, etc.)onError={(error, ctx) => tracker.capture(error, ctx)}
Silence noisy expected failuresonError={(error, ctx) => { if (!isExpected(error)) defaultLog(error, ctx); }}
Halt undo on first failureRe-throw inside onError — but expect React to surface it