DEV Community

React useIntersectionObserver Hook: Lazy Load & Detect Visibility (2026)

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 + disconnect dance, 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 accepts null / undefined too, so observing a conditionally-rendered element is safe - the hook simply waits.)
  • callback - the standard IntersectionObserverCallback, (entries, observer) => void. You get the raw IntersectionObserverEntry[], so you decide what visibility means for your case.
  • options - the native IntersectionObserverInit: { 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) from 0 to 1 for how much of the target must be visible before the callback fires. 0 (the default) fires the instant a single pixel crosses; 1 waits 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 by entry.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 scroll listener plus getBoundingClientRect() is the wrong tool for "is this on screen" - it thrashes the main thread and still misses scroll containers. IntersectionObserver answers 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 a stop() 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), and root (measure against a container, not the viewport).
  • Want just a boolean? useElementVisibility returns [isVisible, stop]. Both are SSR-safe.

Grab it from @reactuses/core and delete your scroll-listener boilerplate.

Comments

No comments yet. Start the discussion.