useMutationObserver
A callback-ref hook that watches DOM changes with MutationObserver — filtered attributes, rAF-batched records, and a disconnect/write/observe guard against self-feeding loops.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-mutation-observer.jsonPrompt
Build a React + TypeScript "useMutationObserver" hook (no dependencies beyond
React; uses the browser MutationObserver API only).
Contract
- `useMutationObserver<T extends Element = HTMLElement>(options?: {
childList?: boolean; attributes?: boolean; characterData?: boolean;
subtree?: boolean; attributeFilter?: readonly string[];
attributeOldValue?: boolean; characterDataOldValue?: boolean;
onMutate?: (records: MutationRecord[], controls: MutationObserverControls)
=> void;
disabled?: boolean }): {
ref: (node: T | null) => void;
lastMutation: MutationRecord | null;
disconnect: () => void; observe: () => void;
takeRecords: () => MutationRecord[] }`.
- `MutationObserverControls = { disconnect; observe; takeRecords }` — the same
three functions are returned from the hook and handed to `onMutate` as its
second argument, so the "write to the observed DOM" recipe reads identically
inside the callback and inside an event handler.
- Every observation flag defaults to `false`; `disabled` defaults to `false`.
- `ref` is a **callback ref**, not a `RefObject` — consumers attach it straight
to the element they want watched: `<div ref={ref} />`.
- `lastMutation` is the last record of the most recent batch, for rendering
"what just happened". Anything that needs every record uses `onMutate`,
which receives the whole batch.
Behavior
- The callback ref owns the entire lifecycle. Called with a node: bail out if
`disabled` or `typeof MutationObserver === "undefined"` (SSR / unsupported),
otherwise construct one observer and `observe(node, init)`. Called with
`null` (unmount, or the node being replaced): unconditionally `disconnect()`,
cancel the pending frame and drop buffered records. This is the whole point
of a callback ref over `useRef` + `useEffect` — an effect reads `.current`
once at mount, so an element that is conditionally rendered (mounted later,
or swapped for a different DOM node under the same slot) is silently never
observed, or keeps being observed after it detaches.
- **`observe()` does NOT deliver an initial callback.** Unlike ResizeObserver
and IntersectionObserver, MutationObserver reports *changes*, never *state*.
So (a) the consumer must read the current DOM itself for the initial value,
and (b) resuming observation (leaving `disabled`, swapping nodes) never
backfills what happened while it was off. Say this in the JSDoc; it is the
single most common wrong assumption about this API.
- Never commit synchronously from the observer callback. Push the delivered
records into a buffer and flush them from one `requestAnimationFrame`:
a batch of records becomes one `setState` + one `onMutate` per frame, and
the "callback → state → relayout → callback" chain is stretched across
frames instead of spinning inside one task.
- rAF batching alone cannot stop a real loop: if the callback writes DOM that is
itself being observed, it feeds itself forever. Ship the fix as API: expose
`disconnect()` / `observe()` (idempotent — re-observing the same node
replaces the registration) so the recipe is
`disconnect() → write → observe()`. Document that any write to observed DOM,
from the callback *or* from an event handler, must be wrapped that way.
- `disconnect()` also drops the not-yet-flushed buffer and cancels the pending
frame, matching the native semantics ("disconnect discards unprocessed
records"). Without that, a guarded write would still get called back by
leftovers from the previous frame and counters would drift.
- `takeRecords()` returns records from *both* queues — the ones already
delivered but still waiting for the next frame, plus the browser's own
undelivered queue — and cancels the pending flush. Use it to drain before
unmount or before handing processing to something else.
- Mirror two rules the DOM spec applies inside `observe()`, otherwise explicit
defaults break them: (1) if `attributeFilter` or `attributeOldValue` is given
and `attributes` is not, treat `attributes` as true — `{ attributeFilter:
["class"] }` alone must work; (2) never pass `attributeOldValue` /
`attributeFilter` when `attributes` is false, or `characterDataOldValue`
when `characterData` is false — the spec makes those combinations throw a
`TypeError`.
- If none of `childList` / `attributes` / `characterData` ends up true,
`observe()` would throw a `TypeError`. Catch it before the call: log one
actionable `console.error` and stay inert, so a misconfiguration is loud but
never breaks the consumer's render.
- Stabilize `attributeFilter` by **content**, not identity: serialize it into a
string key and build the `MutationObserverInit` in a `useMemo` off that key.
Consumers write the array inline (`["class"]`), which is a fresh identity
every render; keyed by identity, the observer is torn down and rebuilt on
every single render and any mutation landing in that gap is lost forever.
- `onMutate` is held in a latest-ref refreshed every render, so it never enters
the callback ref's dependency array — an inline arrow function there would
reconnect the observer on every render for the same reason.
- The init object, `disabled` and "does it observe anything" *are* dependencies:
changing one gives the callback ref a new identity, and React then calls the
old ref with `null` and the new ref with the node — disconnect-and-re-observe
happens for free, no extra `useEffect`.
- `disabled: true` stops observation and freezes `lastMutation` rather than
clearing it, so a paused panel does not flash back through its empty branch.
- Every hook instance owns its own observer, buffer and state; two hooks may
watch the same node with different options (e.g. one filtered to `class`,
one unfiltered) without interfering.
Rendering & styling
- The hook renders nothing. Consumers render the mutation log / diff badge /
"content changed" banner themselves, with semantic tokens only (`bg-card`,
`bg-muted/40`, `border`, `text-muted-foreground`, `bg-primary/10`) merged
through `cn()`.
- **Render that UI outside the observed subtree.** Rendering the log inside the
container you are watching is exactly the self-feeding loop, just spelled in
JSX instead of `appendChild`.
- Any transition on "something changed" must respect `prefers-reduced-motion`;
the change must still be reported with animation disabled.
Customization levers
- Observation scope: `childList` / `attributes` / `characterData` × `subtree`
is the whole cost model — the narrower the scope, the fewer records. Start
with the narrowest set that answers your question, and reach for
`attributeFilter` before reaching for "attributes: true + filter in JS".
- Old values: `attributeOldValue` / `characterDataOldValue` are opt-in because
they make records heavier; turn them on only when you render a before/after.
- Commit policy: the rAF flush is one knob — swapping it for a debounce timer
(commit N ms after mutations settle) or for a synchronous commit (when the
consumer needs the callback before the next paint) is a two-line change and
the rest of the hook is unaffected.
- Batch shape: `lastMutation` is deliberately one record. If a consumer wants
the batch in state, widen it to `{ records, record }` in the flush — the
buffer already holds the whole batch.
- Watching many elements: this contract is one element per hook call. For a
set of nodes, either call the hook per node or observe a common ancestor with
`subtree: true` and demultiplex by `record.target`.
- Auto-cleanup on unmount is unconditional; if a consumer must not lose the
final records, call `takeRecords()` from their own cleanup before the node
detaches.Concepts
- Change log, not a snapshot —
observe()on a MutationObserver delivers nothing at all until something actually changes, which is the opposite of ResizeObserver and IntersectionObserver (both fire immediately with the current state). "What does this DOM look like right now" must be read by hand once, and observation gaps (disabled, node swaps) are never backfilled. - Self-feeding loop — the failure mode unique to this API: the callback writes DOM that is itself observed, which queues another record, which calls the callback again. It is not prevented by batching, only slowed down to one lap per frame. The cure is a scope hole in time:
disconnect()→ write →observe(), both calls idempotent so it can be applied anywhere a write happens. - Callback ref over
RefObject+ effect — an effect reads.currentonce at mount. If the watched element is conditionally rendered (mounted a tick later, or swapped for a different node under the same slot), the effect never reruns and the hook silently watches nothing, or keeps watching a detached node. React calls a callback ref on every mount, replace and unmount, so "which node am I watching right now" is correct by construction. - Options stabilized by content —
attributeFilter: ["class"]written inline is a new array every render. If the observer is keyed on that identity it is destroyed and rebuilt on every render, and every mutation that lands between the teardown and the rebuild is lost with no trace. Serializing the filter into a key makes "same filter" mean "same observation session". - Filter and subtree are the cost model —
attributeFilterdecides which attributes produce records,subtreedecides how deep the watch reaches; a broad watch on a busy container is the difference between a handful of records and thousands per second. Narrow first, widen only when a real record goes missing. - Two queues, one drain — records can be sitting in the browser's undelivered queue or in the hook's buffer waiting for the next frame.
takeRecords()empties both and cancels the pending flush, which is what "drain everything before I tear this down" actually requires.
useSpeechRecognition
Speech-to-text on the Web Speech API — interim vs settled transcripts, a continuous mode that survives the engine's silence timeout without becoming unstoppable, and errors split into benign / recoverable / blocked.
useStep
A step machine for multi-step flows — async can-go-next gates with a pending flag, visited/completed sets, loop, clamping when steps change, controlled or uncontrolled.