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.
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
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
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:
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:
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]);
Verification Steps
- Toggle the element type and confirm the observer follows: log inside the callback and check for a
nullcall 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.
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.currentis available. With a callback ref there is no.currentunless you assign one. - Dropping the unmount effect. The
nullcall 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.
Related
- React Observer Hooks — the identity problems this pattern sidesteps
- Building a TypeScript useIntersectionObserver Hook — typing the returned tuple
- Fixing Doubled Observer Callbacks in React Strict Mode — the cleanup contract this satisfies automatically
↑ Back to React Observer Hooks