useUndoRedo
A state container with linear undo/redo history — keystroke coalescing, forced breakpoints, a bounded stack, and stable callbacks.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-undo-redo.jsonPrompt
Build a React + TypeScript "useUndoRedo" hook (React only — no third-party
dependencies, no browser APIs beyond Date.now()).
Contract
- useUndoRedo<T>(initialPresent: T, options?: UseUndoRedoOptions<T>): UseUndoRedoResult<T>
- Options (all optional):
- limit = 100 — how many undo steps to keep (the max length of `past`).
- coalesceMs = 0 — merge window in ms; 0 disables merging.
- isEqual = Object.is — "is the new value the same state?" predicate.
- Result: { state, set, reset, undo, redo, clear, canUndo, canRedo, past,
future, index, size }
- state — the current value (the timeline's present).
- set(next | (prev) => next, { coalesce?: boolean }) — commit a new value.
- reset(next) — adopt a new present and drop all history (opening another
document).
- undo() / redo() — step along the timeline; both are no-ops at the ends.
- clear() — keep the present, drop past and future ("this is the new
baseline", e.g. right after a successful save).
- past / future — readonly snapshot arrays, oldest-first / nearest-first;
past's last item is what one undo returns to, future's first is what one
redo goes to.
- index = past.length (the present's position, 0 = at the bottom),
size = past.length + 1 + future.length.
- initialPresent is captured once on mount, exactly like useState's initial
argument; passing a different value on a later render is ignored — call
reset(next) to adopt a new baseline.
Behavior
- One reducer owns { past, present, future, lastSetAt }; every transition is
pure, so StrictMode's double-invoke cannot corrupt the timeline.
- Coalescing: if the gap between two set() calls is <= coalesceMs, the second
one OVERWRITES the present instead of pushing a new entry — a burst of
typing collapses into a single undo step. The window is rolling: every set
re-anchors it (lastSetAt), so a continuous burst keeps merging and only a
pause longer than coalesceMs opens a new entry.
- The timestamp is read with Date.now() inside set() and passed into the action.
Never read the clock during render or inside the reducer — both must stay
pure (and the lint rules that enforce purity will reject it).
- set(next, { coalesce: false }) is a forced breakpoint: it always pushes a new
entry AND zeroes the anchor, so neither the keystrokes before it nor the ones
after it merge into it. Use it for atomic operations — paste, bulk delete,
applying a template.
- undo / redo / reset / clear zero the anchor too. Without that, the first
keystroke after an undo would merge into the snapshot you just restored,
overwriting it in place and leaving no history entry behind.
- Any successful set clears future. This is linear history: once you edit after
an undo, the redo branch is gone (no history tree).
- limit caps past only. On push, overflow drops from the OLDEST end, so undoing
all the way down stops at the oldest retained snapshot rather than at the
original initialPresent — document that, it surprises people. Clamp limit to
>= 1 (limit 0 would make Undo a button that silently does nothing) and treat
a non-finite limit as the default. A shrunk limit converges on the next push
instead of truncating existing history immediately.
- isEqual match => the entire set is dropped: present unchanged, nothing
pushed, and the reducer returns the SAME state object so React bails out of
the re-render. This is what keeps "user typed a character and deleted it" or
"clicked the same preset twice" out of the history.
- Stable identities: set / undo / redo / reset / clear are useCallback([]) over
the reducer's dispatch, and the options (limit / coalesceMs / isEqual) live in
a latest-ref refreshed in an effect. Consumers write isEqual as an inline
arrow function, so feeding the options into a dep array would hand back new
callbacks on every render and blow up any consumer effect that depends on them
(Maximum update depth exceeded).
- Empty past/future share one module-level empty array, so a dep array on
`future` does not fire on every commit that leaves it empty.
Rendering & styling
- The hook renders nothing — it returns state plus five stable callbacks, and
the consumer owns all UI. For the toolbar around it:
- Drive Undo/Redo from canUndo/canRedo with `disabled`, never by hiding them;
icon-only buttons get an aria-label.
- Semantic tokens only: bg-card / border for the editor surface, bg-muted for
past snapshots, bg-primary + text-primary-foreground for the present one,
text-muted-foreground (plus border-dashed) for the redo tail.
- focus-visible:ring-2 ring-ring on every control.
- When binding ⌘Z / ⌘⇧Z (Ctrl+Z / Ctrl+Y on Windows) inside a textarea,
call event.preventDefault() — otherwise the browser's own undo stack fires
as well and one keypress moves two steps.
- Nothing here needs animation; any decoration you add must be gated behind
prefers-reduced-motion.
Customization levers
- Snapshot shape — T is whatever you put in. Use { text, selectionStart,
selectionEnd } instead of a bare string if undo should restore the caret too;
use the whole board object for drag-and-drop reordering. The timeline logic
does not change.
- Grouping strategy — coalesceMs is a time heuristic. For semantic grouping,
always pass { coalesce: false } and call set once per meaningful command, or
thread an "edit kind" tag through the action and merge only when the kind
matches the previous one.
- Memory — for large snapshots (canvas bitmaps, long documents) lower limit, or
store patches instead of full copies (make T a diff and rebuild on undo).
- Keyboard scope — bind the shortcut on the editor element for a local history,
or on window for an app-wide one (then skip it while focus sits in an
unrelated input).
- Persistence — pair it with a storage hook: persist only `state` (cheap), or
the whole { past, present, future } triple for a resumable history, and
rehydrate through reset() or a custom initial state.Concepts
- Past · present · future — the whole model is three fields: a stack of older snapshots, the value you render, and a stack of undone ones.
undomoves one item from past to future through present;redomoves it back.index/sizeare just a readable projection of the two lengths. - Coalescing — a rolling time window that decides whether a commit overwrites the present or pushes it. It is what stands between "one undo step per word" and "one undo step per keystroke"; the window re-anchors on every
set, so it groups bursts rather than fixed time slices. - Forced breakpoint —
set(next, { coalesce: false })opts one commit out of merging and cuts the chain on both sides, so an atomic action (paste, bulk delete, template apply) can never be swallowed by the typing around it. - Linear history — a new commit clears
future. Editing after an undo permanently discards the redo branch, which is what every mainstream editor does; a branching history tree is a different (much larger) data structure. - Bounded history —
limittrims from the oldest end, so the timeline stays cheap but "undo all the way" lands on the oldest retained snapshot, not on the value you mounted with. Worth surfacing in your UI when the limit is small. - Stable callback identity — the returned callbacks never change identity because state lives in a reducer and the options live in a latest-ref. Consumers can put
set/undostraight into a dep array or auseEffectwithout triggering the classicMaximum update depth exceededloop.
useBroadcastChannel
An SSR-safe hook that broadcasts messages between same-origin tabs over BroadcastChannel, with capability detection and an optional local echo.
useControllableState
A useState-shaped hook that lets one component serve both controlled (value/onChange) and uncontrolled (defaultValue) callers, with a stable setter and prop-accurate updaters.