June 30, 2026
React useIntersectionObserver Hook: Lazy Load & Detect Visibility (2026)
You want to load an image only when it scrolls near the viewport. Or fire an analytics event the first time a card is actually seen. Or trigger “load more” when the user reaches the bottom of a list. Every one of these is the same question — is this element on screen yet? — and for years the answer was a scroll listener that fired hundreds of times a second, re-read getBoundingClientRect() on each tick, and still managed to miss the edge cases.
IntersectionObserver is the browser API that answers that question correctly, asynchronously, and off the main thread. useIntersectionObserver is the hook that wires it into React without the useEffect/useRef/cleanup boilerplate — and without the leak-on-unmount and stale-closure bugs the hand-rolled version always ships. This post covers the real @reactuses/core API, the three patterns you’ll actually reach for, and how to tune threshold, rootMargin, and root. SSR-safe and typed.
Why Not Just Use a Scroll Listener?
The old way to know whether an element was visible looked like this: listen to scroll, and on every event measure the element against the viewport.
useEffect(() => {
function onScroll() {
const rect = el.getBoundingClientRect();
if (rect.top < window.innerHeight) {
setVisible(true);
}
}
window.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);
This has two problems baked in. First, scroll fires on the main thread, dozens of times per second, and getBoundingClientRect() forces a synchronous layout each time — that’s exactly the recipe for janky scrolling. Second, it only catches elements crossing the viewport; the moment your scroll happens inside a container, you’re re-deriving geometry by hand.
IntersectionObserver flips the model. You hand the browser a target and a threshold, and it tells you — asynchronously, batched, off the scroll path — when the element crosses that threshold. No measuring, no listener thrash. The only thing left to get wrong is the React lifecycle around it, and that’s the part the hook owns.
Here’s the naive in-component version, which has the same three bugs every hand-rolled observer does:
function LazySection({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const [seen, setSeen] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) setSeen(true); // 🐛 see below
}, { threshold: 0.1 });
io.observe(el);
return () => io.disconnect();
}, []);
return <div ref={ref}>{seen ? children : null}</div>;
}
- It leaks if you forget the cleanup. Drop the
return () => io.disconnect()— and people do, especially when refactoring — and the observer outlives the component. - It captures stale closures. The moment the callback references a prop or a second piece of state, the observer created on mount freezes whatever those were at mount time, not when it fires.
- It spreads. Every lazy section, every “viewed” tracker, every infinite-scroll sentinel re-implements the same
useRef+observe+disconnectdance, and each copy is a fresh chance to ship one of the first two bugs.
A hook fixes all three in one place.
The API
useIntersectionObserver takes three arguments and returns a stop function:
const stop = useIntersectionObserver(target, callback, options?);
target— what to observe. A React ref, a raw element, or a getter() => element. (It acceptsnull/undefinedtoo, so observing a conditionally-rendered element is safe — the hook simply waits.)callback— the standardIntersectionObserverCallback,(entries, observer) => void. You get the rawIntersectionObserverEntry[], so you decide what visibility means for your case.options— the nativeIntersectionObserverInit:{ root, rootMargin, threshold }. All optional.- returns
stop()— call it to disconnect the observer early (more on this below). The hook also calls it for you automatically on unmount.
The deliberate design choice here is that the hook is callback-based, not boolean-based. It doesn’t decide for you that “intersecting” means visible — because depending on the job, it might mean “10% visible”, “fully visible”, or “within 200px of the viewport”. You read entry.isIntersecting (or entry.intersectionRatio) and act. If all you want is a plain boolean, there’s a convenience sibling for that — see below.
Internally the callback is kept in a ref (via useLatest), so it never goes stale — bug #2 is gone even when your callback closes over props. And because the observer is only ever constructed inside an effect, the hook is SSR-safe: nothing touches IntersectionObserver during render.
Pattern 1: Lazy-Load an Image
The canonical use. Render a placeholder, and only swap in the real <img> once the container is about to enter the viewport. Note the stop() call — once we’ve loaded, we never need the observer again, so we disconnect it immediately.
import { useRef, useState } from 'react';
import { useIntersectionObserver } from '@reactuses/core';
function LazyImage({ src, alt }: { src: string; alt: string }) {
const ref = useRef<HTMLDivElement>(null);
const [loaded, setLoaded] = useState(false);
const stop = useIntersectionObserver(
ref,
([entry]) => {
if (entry.isIntersecting) {
setLoaded(true);
stop(); // one-shot: stop observing once we've committed to loading
}
},
{ rootMargin: '200px' }, // start loading 200px before it scrolls in
);
return (
<div ref={ref} style={{ minHeight: 200 }}>
{loaded ? <img src={src} alt={alt} /> : <div className="skeleton" />}
</div>
);
}
Two things make this feel right. The rootMargin: '200px' grows the observer’s “viewport” by 200px on every side, so the fetch kicks off before the image is actually visible and the user rarely sees the skeleton. And stop() inside the callback means a list of 500 lazy images ends up with zero live observers once they’ve all loaded — no lingering work as you keep scrolling.
Pattern 2: Fire-Once “Viewed” Analytics
Tracking which sections a user actually scrolled to is the same shape — but here you genuinely want it to fire exactly once, so the stop() is doing real work.
import { useRef } from 'react';
import { useIntersectionObserver } from '@reactuses/core';
function TrackedSection({ id, children }: { id: string; children: React.ReactNode }) {
const ref = useRef<HTMLElement>(null);
const stop = useIntersectionObserver(
ref,
([entry]) => {
if (entry.isIntersecting) {
analytics.track('section_viewed', { id });
stop(); // count each section once, not once per scroll-past
}
},
{ threshold: 0.5 }, // "viewed" = at least half on screen
);
return <section ref={ref}>{children}</section>;
}
Here threshold: 0.5 encodes a product decision — a section only counts as “viewed” once 50% of it is on screen, so a fast scroll past the top edge doesn’t inflate your numbers. The stop() guarantees one event per section per page load even if the user scrolls it in and out repeatedly.
Pattern 3: Infinite-Scroll Trigger
Put an empty sentinel <div> at the bottom of a list and fetch the next page when it intersects. Note that here we don’t call stop() — we want the trigger to keep firing for every page.
import { useRef } from 'react';
import { useIntersectionObserver } from '@reactuses/core';
function Feed({ items, loadMore, hasMore }: FeedProps) {
const sentinel = useRef<HTMLDivElement>(null);
useIntersectionObserver(sentinel, ([entry]) => {
if (entry.isIntersecting && hasMore) {
loadMore();
}
});
return (
<>
{items.map((it) => <Row key={it.id} item={it} />)}
{hasMore && <div ref={sentinel} style={{ height: 1 }} />}
</>
);
}
Because the callback is always the latest one (no stale closure), loadMore and hasMore are read fresh every time the sentinel intersects — the bug that bites the hand-rolled useEffect version doesn’t exist here. If you want this whole pattern packaged, useInfiniteScroll builds exactly this on top, including the scroll-container plumbing.
Tuning: threshold, rootMargin, and root
The third argument is the native IntersectionObserverInit, passed straight through. Three knobs, each answering a different question:
useIntersectionObserver(ref, callback, {
threshold: 0.5, // HOW MUCH must be visible to count?
rootMargin: '200px', // grow/shrink the trigger boundary
root: containerRef.current, // WHAT are we measuring against?
});
threshold— a number (or array) from0to1for how much of the target must be visible before the callback fires.0(the default) fires the instant a single pixel crosses;1waits until the element is fully on screen. Pass an array like[0, 0.25, 0.5, 0.75, 1]to get a callback at each step — useful for scroll-linked animations driven byentry.intersectionRatio.rootMargin— a CSS-margin string that inflates or deflates the root’s bounding box before intersection is computed. Positive values ('200px') fire early — the lazy-load-ahead trick from Pattern 1. Negative values ('-100px 0px') fire late, e.g. “only count this as viewed once it’s 100px past the top edge.”root— the element you’re measuring against. Defaults to the browser viewport; set it to a scroll container’s element when your list scrolls inside a<div>rather than the page.
The stop() Return Value
The returned stop() disconnects the observer. You usually don’t need it — the hook auto-disconnects on unmount — but it’s the clean way to express one-shot observation, as in Patterns 1 and 2: the first time the element intersects, do the work and stop watching. That’s both a correctness win (the event fires exactly once) and a performance one (no live observer trailing behind a long, already-loaded list).
Just Want a Boolean?
Sometimes you don’t care about entries or thresholds — you just want a reactive isVisible flag for the whole viewport. useElementVisibility wraps useIntersectionObserver and hands you exactly that, as a tuple with its own stop:
import { useRef } from 'react';
import { useElementVisibility } from '@reactuses/core';
function FadeIn({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const [visible] = useElementVisibility(ref);
return (
<div ref={ref} className={visible ? 'fade fade-in' : 'fade'}>
{children}
</div>
);
}
Reach for useElementVisibility when a boolean is all you need, and drop down to useIntersectionObserver the moment you want a custom root, a non-default threshold, multiple thresholds, or the raw entry. Same engine, two ergonomics.
SSR Safety
useIntersectionObserver is safe to render on the server. It constructs the IntersectionObserver only inside an effect — which React never runs on the server — and the underlying element lookup returns undefined outside the browser, so there’s no typeof window guard to write and no hydration mismatch to chase. Drop it into a Next.js, Remix, or Astro component as-is. (If SSR-safety is a recurring theme in your codebase, SSR-Safe React Hooks goes deeper.)
The Visibility & Size Family
useIntersectionObserver is the low-level primitive in a family of DOM-watching hooks. Pick by what you actually want back:
| Hook | Gives you | Reach for it when… |
|---|---|---|
useIntersectionObserver | raw entries, a stop() | you want full control: custom root, thresholds, one-shot |
useElementVisibility | [isVisible, stop] | a plain “is it on screen?” boolean is enough |
useInfiniteScroll | a load-more callback wired up | you’re building a paginated/infinite list |
useResizeObserver | a callback on size change | the element’s size matters, not its visibility |
useElementSize | { width, height } as state | you just need live width/height |
useElementBounding | the full bounding rect | you need viewport-relative position (changes on scroll) |
For the full tour of how these compose, see React Observer Hooks: 7 Ways to Watch the DOM.
Takeaways
- A
scrolllistener plusgetBoundingClientRect()is the wrong tool for “is this on screen” — it thrashes the main thread and still misses scroll containers.IntersectionObserveranswers it correctly, batched and off the scroll path. useIntersectionObserver(target, callback, options?)wires it into React: hand it a ref, a callback that receives the raw entries, and the native options. It returns astop()and auto-disconnects on unmount.- It’s callback-based on purpose — you decide what “visible” means via
entry.isIntersecting/entry.intersectionRatio. The callback is never stale, so it reads fresh props every time it fires. - Call
stop()inside the callback for one-shot jobs (lazy-load, fire-once analytics); skip it for repeating triggers (infinite scroll). - Tune with
threshold(how much must show),rootMargin(fire early/late), androot(measure against a container, not the viewport). - Want just a boolean?
useElementVisibilityreturns[isVisible, stop]. Both are SSR-safe.
Grab it from @reactuses/core and delete your scroll-listener boilerplate.