The standard recipe attaches an observer in a mount effect and cleans up on unmount. It is correct for a component whose element never changes, and quietly wrong for every component whose element does.

Problem / Scenario Context

A useInView hook is used on a card whose root element is conditionally rendered — an <article> normally, a <section> when the card is featured, or the same tag with a different key. React handles this by unmounting the old node and mounting a new one, without unmounting the component.

The mount effect, with an empty dependency array, does not re-run. The observer is still watching the old node, which is now detached. The new node is never observed. Nothing errors; the card simply never reveals, and only on the code path where the element type changes — which is why it survives review and ships.

The same failure appears in a list whose items are keyed by index and then reordered, and in any component that swaps its root element between loading and loaded states.

Mechanics Explanation

useRef gives a mutable box. React writes the element into ref.current during the commit phase and writes null when it is detached, but nothing notifies you — it is a silent assignment. A mount effect reads that box once, at a moment when it happens to hold the first element, and then never looks again.

A callback ref is a function React calls with the element when it attaches and with null when it detaches. That is precisely the pair of events an observer needs: observe on attach, unobserve on detach. There is no dependency array to reason about, because the callback is invoked by the reconciler exactly when the DOM changes.

One detail makes the difference practical: React calls a callback ref again whenever the function identity changes. An inline arrow is a new function every render, so React detaches and reattaches on every render — which is why the callback must be memoised with useCallback.

When the Element Is Replaced, Not the ComponentA render sequence where a card's root element changes from an article to a section while the component stays mounted. With a ref object and a mount effect, the effect does not re-run, so the observer keeps watching the detached article and never sees the section. With a callback ref, React invokes it with null for the article and with the section immediately afterwards, so the observer unobserves one and observes the other.The component stays mounted; only its root element changesuseRef + useEffect([])effect ran once, on the <article>still observing a detached node; <section> never registereduseCallback refref(null) → unobserve(<article>)ref(<section>) → observe(<section>)Both calls happen in the same commit, so there is no frame in which nothing is observed.An inline arrow as the ref is a new function every render, so React detaches and reattaches every time —which is why the callback has to be memoised.

Comparison Table: The Two Approaches

Situation useRef + mount effect useCallback ref
Element never changes correct correct
Element type swaps observes a detached node correct
key changes on the element observes a detached node correct
Conditional render toggles it off and on stops working after the first toggle correct
Options change needs the options in the dependency array needs them in the useCallback deps
Reading the element outside the callback ref.current is available store it yourself if you need it
Strict Mode double-invocation handled by the cleanup return handled by the null call

The last two rows are the honest trade-offs. A callback ref does not give you a .current to read elsewhere, so a component that also needs the element for something else has to keep its own reference — usually by assigning inside the callback.

Minimal Reproducible Example

TypeScript
function Card({ featured }: { featured: boolean }) {
  const ref = useRef<HTMLElement>(null);
  const [seen, setSeen] = useState(false);

  useEffect(() => {
    if (!ref.current) return;
    const io = new IntersectionObserver(([e]) => e.isIntersecting && setSeen(true));
    io.observe(ref.current);
    return () => io.disconnect();
  }, []);                                     // ← runs once, on whichever element existed first

  return featured
    ? <section ref={ref as never}></section>
    : <article ref={ref as never}></article>;   // toggling `featured` breaks it silently
}

Production-Safe Solution

TypeScript
function useInView(options?: IntersectionObserverInit): [(el: Element | null) => void, boolean] {
  const [inView, setInView] = useState(false);
  const cleanup = useRef<(() => void) | undefined>(undefined);

  // Serialise the options so the identity of the object cannot force a reattach
  const optsKey = JSON.stringify({
    rootMargin: options?.rootMargin ?? '0px',
    threshold: options?.threshold ?? 0,
  });

  const ref = useCallback((el: Element | null) => {
    cleanup.current?.();                       // detach from whatever we were watching
    cleanup.current = undefined;
    if (!el) return;                           // React is telling us the node is gone

    const io = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting), options);
    io.observe(el);
    cleanup.current = () => io.disconnect();
     
  }, [optsKey]);                               // NOT [options] — a new object every render

  useEffect(() => () => cleanup.current?.(), []);   // final teardown on unmount

  return [ref, inView];
}

Two things are load-bearing.

The optsKey dependency. Depending on options directly means a new object identity every render, so useCallback returns a new function, so React detaches and reattaches the observer on every render — the same identity problem that plagues effect dependency arrays, arriving by a different door.

The unmount effect. A callback ref is invoked with null on unmount in most cases, but keeping an explicit teardown covers the paths where it is not, and costs one line.

Using it is unremarkable, which is the point:

TypeScript
function Card({ featured }: { featured: boolean }) {
  const [ref, inView] = useInView({ rootMargin: '200px 0px' });
  const Tag = featured ? 'section' : 'article';
  return <Tag ref={ref} data-seen={inView}></Tag>;   // swapping tags now works
}

If the element is also needed elsewhere, capture it in the same callback rather than reaching for a second ref:

TypeScript
const elRef = useRef<Element | null>(null);
const ref = useCallback((el: Element | null) => {
  elRef.current = el;            // available to the rest of the component
  // …attach/detach as above
}, [optsKey]);

Depend on the Serialised Options, Not the ObjectThree renders of a component. With the options object in the useCallback dependency array, each render produces a new object identity, so a new callback, so React detaches and reattaches the observer three times. With a serialised key the identity is stable across renders, the callback is reused, and the observer stays attached throughout.useCallback(fn, [options])render 1attachrender 2detach + attachrender 3detach + attacha new object every render → a new callback → React reattaches, and the element loses its initial entry each timeuseCallback(fn, [optsKey])render 1attachrenders 2 and 3 — same callback identityobserver stays attached; no churn

Verification Steps

  • Toggle the element type and confirm the observer follows: log inside the callback and check for a null call followed by an element call.
  • Count attachments across renders with a counter in the callback; forcing three re-renders should not produce three attachments.
  • Confirm the detached node is released with a heap snapshot after several toggles — no detached elements should remain.
  • Test under Strict Mode, where the double-invocation should produce a matched attach, detach, attach rather than two live observers.

Three Ways an Element Gets ReplacedThree situations in which React swaps the DOM node while the component stays mounted: a conditional tag change, a key change on a list item, and a wrapper toggled off and on. A mount effect sees none of them; a callback ref sees each as a null call followed by an element call.The component never unmounts in any of theseA conditional tag changeAn article element becomes a section when a prop flips.A key change on a list itemReact treats it as a different element in the same position.A wrapper toggled off and onEach toggle produces a fresh node with a fresh identity.A callback ref sees all three. A mount effect with an empty dependency array sees none.

Common Mistakes to Avoid

  • An inline arrow as the ref. Reattaches on every render, which loses the element's initial entry each time and reads as "my observer fires twice, then never".
  • Depending on the options object. Same problem, one level up.
  • Assuming ref.current is available. With a callback ref there is no .current unless you assign one.
  • Dropping the unmount effect. The null call covers most paths, not all, and the extra line is cheap insurance.

FAQ

What exactly breaks with a mount effect?

Nothing, until the rendered element is replaced without the component unmounting — a tag swap, a key change, a conditional render toggling off and on. The effect does not re-run, so the observer keeps watching a node that is no longer in the document and the replacement is never registered.

Why does the callback ref need useCallback?

Because React invokes a callback ref again whenever its function identity changes. An inline arrow is a new function on every render, so React detaches and immediately reattaches the observer each time, which loses the element's initial entry and churns for no reason.

Can I still read the element elsewhere in the component?

Not through the ref itself — a callback ref has no current property. Assign the element to your own ref object inside the callback if the rest of the component needs it, which keeps a single source of truth rather than adding a second ref that can drift.

Does this help with React Strict Mode?

It handles it cleanly rather than fixing anything specific to it. The synthetic remount invokes the callback with null and then with the element again, producing a matched detach and attach, which is exactly the behaviour a correct cleanup would have given.


↑ Back to React Observer Hooks