Coalescing Bursts
A single character keystroke or a 60Hz slider drag should not produce one
history entry per frame. Use coalesceKey to merge consecutive pushes that
arrive within the active coalescing window (coalesceWindowMs) of each
other.
import { useUndoableState } from "react-amnesia";
export function TitleEditor() {
const [title, setTitle] = useUndoableState("Untitled", {
label: "Edit title",
coalesceKey: "edit:title",
});
return <input value={title} onChange={(e) => setTitle(e.target.value)} />;
}
A burst of 5 keystrokes with the same coalesceKey produces one entry
on the stack. A single Ctrl+Z reverts the whole burst back to its
pre-burst value.
How merging works
The merged entry keeps:
- the latest
redo(so a future Redo replays the final state) - the earliest
undo(so a single Undo reverts to the pre-burst state) - the latest
label,coalesceKey, andmetafor display purposes
When coalescing does NOT happen
- Different
coalesceKeybetween consecutive pushes - Empty
coalesceKey(treated as "do not coalesce") - More than the effective coalescing window between pushes
- The previous entry has already been undone (not on top of the past stack)
- The new push sets
coalesceWindowMs <= 0 - The push happens through
tx.pushinside a transaction — composite entries never coalesce with neighbors
Tuning the window
<AmnesiaProvider coalesceWindowMs={1000}>{/* coalesce within 1s instead of the default 400ms */}</AmnesiaProvider>
A longer window collapses more aggressively (good for slow typists, bad for batched edits where the user expects each pause to checkpoint). Tune per-scope when surfaces have different cadences:
<AmnesiaProvider scopes={{ canvas: { coalesceWindowMs: 50 } }}>
Per-command override
Imperative push(...) can override the scope window per command:
push({
coalesceKey: "drag:node-42",
coalesceWindowMs: Number.POSITIVE_INFINITY,
redo: applyFrame,
undo: restoreFrame,
});
Resolution rules:
command.coalesceWindowMs(when provided) wins over the scope defaultNumber.POSITIVE_INFINITYdisables time-bound checks (pure adjacency)<= 0disables coalescing for that pushundefinedfalls back to the scope/providercoalesceWindowMs
Coalesce keys, not labels
coalesceKey is the merge identity — make it unique per logical edit
target ("edit:title", "drag:volume", "resize:node-42"). Don't reuse
the user-facing label here; labels can change but the coalesce key
should be stable across a single editing burst.
Don't coalesce across async commands
Each push's coalesce window is measured at commit time. An async push
that takes 800 ms to settle can race against a fresh keystroke — the
result is unpredictable. Recommendation: do not set coalesceKey on
async commands.