The IntersectionObserver API, often used for infinite scroll, is surprisingly good at not rendering things until they’re actually visible.

Let’s see it in action. Imagine you have a list of items, and you want to load more as the user scrolls down.

import React, { useState, useEffect, useRef } from 'react';

function ItemList() {
  const [items, setItems] = useState([]);
  const [page, setPage] = useState(1);
  const loaderRef = useRef(null);

  const fetchItems = async (pageNum) => {
    // Simulate an API call
    const response = await fetch(`https://api.example.com/items?page=${pageNum}`);
    const data = await response.json();
    return data.items;
  };

  useEffect(() => {
    fetchItems(page).then(newItems => {
      setItems(prevItems => [...prevItems, ...newItems]);
    });
  }, [page]);

  useEffect(() => {
    const observer = new IntersectionObserver(entries => {
      if (entries[0].isIntersecting) {
        setPage(prevPage => prevPage + 1);
      }
    }, {
      root: null, // Use the viewport as the root
      rootMargin: '0px',
      threshold: 0.1 // Trigger when 10% of the target is visible
    });

    if (loaderRef.current) {
      observer.observe(loaderRef.current);
    }

    return () => {
      if (loaderRef.current) {
        observer.unobserve(loaderRef.current);
      }
    };
  }, []); // Dependency array is empty to run only once on mount

  return (
    <div>
      {items.map((item, index) => (

        <div key={index} style={{ height: '200px', border: '1px solid #ccc', margin: '10px' }}>

          Item {item.id}
        </div>
      ))}

      <div ref={loaderRef} style={{ height: '100px', background: 'lightblue' }}>

        Loading more...
      </div>
    </div>
  );
}

export default ItemList;

In this example, loaderRef points to a div at the bottom of our list. When this div becomes visible in the viewport (thanks to IntersectionObserver), we increment the page state, which triggers another useEffect to fetch more items and append them to our list. The magic is that IntersectionObserver efficiently tells us when to load more, without us having to manually track scroll positions or element offsets.

The core problem this solves is performance. Traditional scroll event listeners fire very frequently. If you have complex logic inside a scroll handler (like checking element positions), you can easily bog down the main thread, leading to janky scrolling and a sluggish user experience. IntersectionObserver is designed to be much more performant because it’s handled by the browser’s compositor and doesn’t necessarily require JavaScript execution on every scroll frame. It only fires a callback when an observed element’s intersection status changes relative to its root (usually the viewport).

The key levers you control are the threshold and rootMargin options. threshold is a single number or an array of numbers between 0.0 and 1.0, indicating the percentage of the target’s visibility the observer’s callback should be executed. A threshold of 0.1 means the callback fires when 10% of the target element is visible. rootMargin is a CSS margin string (like '10px 20px 30px 40px') that applies to the root element’s bounding box before calculating intersections. This is incredibly useful for "preloading" content; you can set rootMargin to a negative value (e.g., '-200px') to trigger the loading of new items when the loader element is still 200 pixels above the viewport.

What most developers miss is that IntersectionObserver’s callback receives an array of IntersectionObserverEntry objects, one for each element being observed. This means you can observe multiple elements with a single observer instance and process their intersection changes in one go. Each entry has properties like isIntersecting (a boolean indicating if the target is currently intersecting the root), intersectionRatio (a number between 0 and 1 representing the visible portion), intersectionRect (the bounding box of the intersection), and target (the DOM element being observed). This allows for fine-grained control over when and how you react to visibility changes.

The next concept you’ll likely encounter is debouncing or throttling your fetchItems call if you anticipate multiple isIntersecting events firing in rapid succession before your state update can complete.

Want structured learning?

Take the full React course →