Hooks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/**
 * The observation controls. They exist for exactly one reason: the most dangerous usage,
 * **writing to the DOM you observe**. Write without stopping observation first and your own
 * write delivers another callback, which writes again… a loop that feeds itself. The recipe is
 * always `disconnect() → write → observe()`, inside the callback and inside event handlers
 * alike (the hook's return value and `onMutate`'s second argument are the same functions).
 */
export interface MutationObserverControls {
  /**
   * Stop observing (idempotent). Like the native `disconnect()`, it **discards** records that

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-mutation-observer.json

Prompt

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 snapshotobserve() 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 .current once 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 contentattributeFilter: ["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 modelattributeFilter decides which attributes produce records, subtree decides 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.

On This Page