"Is this element on screen?" powers a huge amount of UI: lazy-loading images as they scroll into view, infinite-scroll sentinels, fade-in-on-reveal animations, viewability analytics. The efficient way to answer it is the IntersectionObserver API — the browser watches an element against a viewport and calls you back when its visibility crosses a threshold, with none of the scroll-listener jank. useIntersectionObserver wraps that API into a hook.
Implement useIntersectionObserver(elementRef, options). Observe elementRef.current, keep the latest IntersectionObserverEntry in state, and return it (so callers read entry?.isIntersecting). Support threshold, root, rootMargin, and a freezeOnceVisible flag that stops observing after the element first appears.
function useIntersectionObserver(elementRef, {
threshold = 0, root = null, rootMargin = '0%', freezeOnceVisible = false,
}) {
// returns the latest IntersectionObserverEntry (or undefined)
}
const ref = useRef(null);
const entry = useIntersectionObserver(ref, { threshold: 0.5 });
const isVisible = entry?.isIntersecting;
<div ref={ref}>{isVisible ? <Chart /> : <Placeholder />}</div>
// One-shot reveal: freeze so it never flips back to hidden.
const entry = useIntersectionObserver(ref, { freezeOnceVisible: true });
new IntersectionObserver(cb, options), observe(node), and disconnect() in the cleanup.elementRef.current is null or IntersectionObserver isn't available (older browsers / SSR).freezeOnceVisible — once isIntersecting is true, stop observing and keep the last entry — ideal for lazy-load and reveal-once.You'll create an IntersectionObserver in an effect, push its entry into state, and disconnect on cleanup — with a derived frozen flag that keys the effect off once the element has been seen.
The old way to answer "is this visible?" was a scroll listener doing getBoundingClientRect math on every frame — expensive and janky. IntersectionObserver flips it around: you hand the browser an element and a threshold, and it asynchronously calls you only when the element's visibility actually crosses that line. The hook's job is lifecycle plumbing: construct the observer when there's an element, route its callback into React state, and tear it down when the element changes or the component unmounts. The one feature with real logic is freezeOnceVisible — stop watching after the first appearance.
An effect owns one observer. When the effect runs, it builds an IntersectionObserver whose callback does setEntry(entry); it observes the node and returns a cleanup that disconnects. That single setEntry is the bridge from the browser's async notification to a React re-render. freezeOnceVisible becomes a derived boolean — "have we already seen it and do we want to freeze?" — that's in the effect's dependency array, so when it flips true, React runs the cleanup (disconnecting) and the effect early-returns without re-observing. The last entry stays frozen in state.
The tempting version observes without cleanup or guards:
function useIntersectionObserverNaive(ref, options) {
const [entry, setEntry] = useState();
useEffect(() => {
const observer = new IntersectionObserver(([e]) => setEntry(e), options);
observer.observe(ref.current); // throws if ref.current is null
// no cleanup -> observer leaks, keeps firing after unmount
}, [options]); // new options object each render -> re-subscribes constantly
}
Three problems. If ref.current is null (first render, conditional element), observe(null) throws. With no cleanup, each re-run leaks an observer that keeps calling setEntry after unmount. And depending on the options object — freshly built every render — re-subscribes on every render. The fix is to guard the node, disconnect in cleanup, and depend on the primitive option values.
const { useState, useEffect } = require('react');
function useIntersectionObserver(
elementRef,
{ threshold = 0, root = null, rootMargin = '0%', freezeOnceVisible = false } = {},
) {
const [entry, setEntry] = useState();
// Freeze after the element has been seen (one-shot reveal / lazy-load).
const frozen = Boolean(entry?.isIntersecting) && freezeOnceVisible;
useEffect(() => {
const node = elementRef?.current;
const hasSupport = typeof IntersectionObserver !== 'undefined';
if (!hasSupport || !node || frozen) return; // nothing to (re)observe
const observer = new IntersectionObserver(([e]) => setEntry(e), {
threshold,
root,
rootMargin,
});
observer.observe(node);
return () => observer.disconnect();
}, [elementRef, threshold, root, rootMargin, frozen]);
return entry;
}
module.exports = { useIntersectionObserver };
The effect first guards: no observer if there's no support, no element, or we've frozen — so observe never sees null. Otherwise it constructs the observer with the primitive options (threshold/root/rootMargin), observes the node, and returns a disconnect cleanup that runs on unmount and whenever a dependency changes. The dependency array is the primitive option values plus frozen (not the options object), so it re-subscribes only when something meaningful changes. frozen is derived: true only when the element has intersected and freezeOnceVisible is set; when it flips true, React disconnects (cleanup) and the effect early-returns, leaving the last entry in state permanently.
useIntersectionObserver(ref, { freezeOnceVisible: true }) on a lazy-loaded image:
entry is undefined, so frozen is false. The effect runs: builds an observer, observe(ref.current). The hook returns undefined → the component shows a placeholder.entry.isIntersecting === true; setEntry(entry) re-renders. Now entry?.isIntersecting is true and freezeOnceVisible is true, so frozen becomes true.frozen changed, so React runs the effect's cleanup (observer.disconnect()), then re-runs the effect body, which hits if (… || frozen) return and creates no new observer.observe(null) — throws when the element isn't mounted yet. Guard on ref.current before observing.disconnect — leaks an observer that fires after unmount. Return observer.disconnect from the effect.IntersectionObserver is undefined on the server and in old browsers; check typeof before constructing.useInView sugar — returning just [ref, isIntersecting] with an internally-created ref (a callback ref) is the ergonomic form popular libraries expose.threshold: [0, 0.25, 0.5, 1] gives graduated intersectionRatio callbacks, enough to drive scroll-linked animations.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
"Is this element on screen?" powers a huge amount of UI: lazy-loading images as they scroll into view, infinite-scroll sentinels, fade-in-on-reveal animations, viewability analytics. The efficient way to answer it is the IntersectionObserver API — the browser watches an element against a viewport and calls you back when its visibility crosses a threshold, with none of the scroll-listener jank. useIntersectionObserver wraps that API into a hook.
Implement useIntersectionObserver(elementRef, options). Observe elementRef.current, keep the latest IntersectionObserverEntry in state, and return it (so callers read entry?.isIntersecting). Support threshold, root, rootMargin, and a freezeOnceVisible flag that stops observing after the element first appears.
function useIntersectionObserver(elementRef, {
threshold = 0, root = null, rootMargin = '0%', freezeOnceVisible = false,
}) {
// returns the latest IntersectionObserverEntry (or undefined)
}
const ref = useRef(null);
const entry = useIntersectionObserver(ref, { threshold: 0.5 });
const isVisible = entry?.isIntersecting;
<div ref={ref}>{isVisible ? <Chart /> : <Placeholder />}</div>
// One-shot reveal: freeze so it never flips back to hidden.
const entry = useIntersectionObserver(ref, { freezeOnceVisible: true });
new IntersectionObserver(cb, options), observe(node), and disconnect() in the cleanup.elementRef.current is null or IntersectionObserver isn't available (older browsers / SSR).freezeOnceVisible — once isIntersecting is true, stop observing and keep the last entry — ideal for lazy-load and reveal-once.You'll create an IntersectionObserver in an effect, push its entry into state, and disconnect on cleanup — with a derived frozen flag that keys the effect off once the element has been seen.
The old way to answer "is this visible?" was a scroll listener doing getBoundingClientRect math on every frame — expensive and janky. IntersectionObserver flips it around: you hand the browser an element and a threshold, and it asynchronously calls you only when the element's visibility actually crosses that line. The hook's job is lifecycle plumbing: construct the observer when there's an element, route its callback into React state, and tear it down when the element changes or the component unmounts. The one feature with real logic is freezeOnceVisible — stop watching after the first appearance.
An effect owns one observer. When the effect runs, it builds an IntersectionObserver whose callback does setEntry(entry); it observes the node and returns a cleanup that disconnects. That single setEntry is the bridge from the browser's async notification to a React re-render. freezeOnceVisible becomes a derived boolean — "have we already seen it and do we want to freeze?" — that's in the effect's dependency array, so when it flips true, React runs the cleanup (disconnecting) and the effect early-returns without re-observing. The last entry stays frozen in state.
The tempting version observes without cleanup or guards:
function useIntersectionObserverNaive(ref, options) {
const [entry, setEntry] = useState();
useEffect(() => {
const observer = new IntersectionObserver(([e]) => setEntry(e), options);
observer.observe(ref.current); // throws if ref.current is null
// no cleanup -> observer leaks, keeps firing after unmount
}, [options]); // new options object each render -> re-subscribes constantly
}
Three problems. If ref.current is null (first render, conditional element), observe(null) throws. With no cleanup, each re-run leaks an observer that keeps calling setEntry after unmount. And depending on the options object — freshly built every render — re-subscribes on every render. The fix is to guard the node, disconnect in cleanup, and depend on the primitive option values.
const { useState, useEffect } = require('react');
function useIntersectionObserver(
elementRef,
{ threshold = 0, root = null, rootMargin = '0%', freezeOnceVisible = false } = {},
) {
const [entry, setEntry] = useState();
// Freeze after the element has been seen (one-shot reveal / lazy-load).
const frozen = Boolean(entry?.isIntersecting) && freezeOnceVisible;
useEffect(() => {
const node = elementRef?.current;
const hasSupport = typeof IntersectionObserver !== 'undefined';
if (!hasSupport || !node || frozen) return; // nothing to (re)observe
const observer = new IntersectionObserver(([e]) => setEntry(e), {
threshold,
root,
rootMargin,
});
observer.observe(node);
return () => observer.disconnect();
}, [elementRef, threshold, root, rootMargin, frozen]);
return entry;
}
module.exports = { useIntersectionObserver };
The effect first guards: no observer if there's no support, no element, or we've frozen — so observe never sees null. Otherwise it constructs the observer with the primitive options (threshold/root/rootMargin), observes the node, and returns a disconnect cleanup that runs on unmount and whenever a dependency changes. The dependency array is the primitive option values plus frozen (not the options object), so it re-subscribes only when something meaningful changes. frozen is derived: true only when the element has intersected and freezeOnceVisible is set; when it flips true, React disconnects (cleanup) and the effect early-returns, leaving the last entry in state permanently.
useIntersectionObserver(ref, { freezeOnceVisible: true }) on a lazy-loaded image:
entry is undefined, so frozen is false. The effect runs: builds an observer, observe(ref.current). The hook returns undefined → the component shows a placeholder.entry.isIntersecting === true; setEntry(entry) re-renders. Now entry?.isIntersecting is true and freezeOnceVisible is true, so frozen becomes true.frozen changed, so React runs the effect's cleanup (observer.disconnect()), then re-runs the effect body, which hits if (… || frozen) return and creates no new observer.observe(null) — throws when the element isn't mounted yet. Guard on ref.current before observing.disconnect — leaks an observer that fires after unmount. Return observer.disconnect from the effect.IntersectionObserver is undefined on the server and in old browsers; check typeof before constructing.useInView sugar — returning just [ref, isIntersecting] with an internally-created ref (a callback ref) is the ergonomic form popular libraries expose.threshold: [0, 0.25, 0.5, 1] gives graduated intersectionRatio callbacks, enough to drive scroll-linked animations.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.