3 min read

Using the Intersection Observer API with React

Using the Intersection Observer API with React

The Intersection Observer API allows us to easily check if two items are intersecting. Here is a simple example that checks when the user has reached the end of the page:

0:00
/

In plain Javascript, you would use the Intersection Observer API as follows:

<html>
  <body>
    <div style="background-color: lightblue; height: 800px">
      very long content
    </div>
    <div id="endOfPage">end of page</div>
    <script>
      const observer = new IntersectionObserver((entries) => {
        console.log(
          "intersection status changed! is intersecting: ",
          entries[0].isIntersecting
        );
      });
      observer.observe(document.querySelector("#endOfPage"));
    </script>
  </body>
</html>

Basically, we would have:

  • an element we want to observe - the "target"
  • an element we want to check against - the "root"; by default this is the viewport
  • a "callback" that the Intersection Observer API will call whenever the "target" goes from not intersecting to intersecting or the other way around

Since you can "observe" more than one "target", the callback accepts an array of entries, where each entry contains information about when the target "entered" the root's boundaries. You can also pass options to customise what the "root" is and an overlap threshold, which you can read more about in the official docs.

Now let's use the same in React.

We can track the "target" element using useRef and we can register the Observer on component mount by using useEffect:

import { useEffect, useRef } from "react";

const App = () => {
  const endOfPage = useRef();
  useEffect(() => {
    const observer = new IntersectionObserver((entries) => {
      console.log(
        "intersection status changed! is intersecting: ",
        entries[0].isIntersecting
      );
    });
    observer.observe(endOfPage.current);
  }, []);

  return (
    <>
      <div style={{ backgroundColor: "lightblue", height: "800px" }}>
        very long content
      </div>
      <div ref={endOfPage}>end of page</div>
    </>
  );
};

export default App;

Now this was a simple use case, but what if you also want to access the component state in the callback?
For example, when building an infinite scroll feature, you might not want to load the next page if one is already being loaded.

If we update the example above with fake data fetching and log the isPending variable, we'll see it always shows up as false, even if the state did update.
This is because the intersection callback captured the values in the state as they were when the observer was registered.

import "./styles.css";
import { useRef, useEffect, useState } from "react";

export default function App() {
  const [isPending, setIsPending] = useState(false);
  const endOfPage = useRef();

  useEffect(() => {
    // pretend we're fetching data on page load
    setIsPending(true);
    setTimeout(() => {
      setIsPending(false);
    }, 5000);
  }, []);

  useEffect(() => {
    const observer = new IntersectionObserver((entries) => {
      console.log(
        `isIntersecting: ${entries[0].isIntersecting}, isPending: ${isPending}`
      );
    });
    observer.observe(endOfPage.current);
  }, []);

  return (
    <>
      <div style={{ backgroundColor: "lightblue", height: "800px" }}>
        {isPending ? "Loading ..." : "very long content"}
      </div>
      <div ref={endOfPage}>end of page</div>
    </>
  );
}

A pattern I commonly see used is simply to add the state variables to the useEffect dependency array. This works and it does make the latest state available, however it also registers and unregisters the Observer API on every effect run, which is not ideal. We don't really need a new observer every time!

So how can we register the Observer only once, but have access to the latest state at all times? We can leverage a technique introduced by Dan Abramov for working with setInterval.

We need a function for the Observer API callback that can persist across renders - a savedCallback.
Then, on each render, we point that callback to a regular handler, that gets updated on every render and has access to the latest state values.

  const savedCallback = useRef();

  const handleIntersection = (entries) => {
    console.log(
      `isIntersecting: ${entries[0].isIntersecting}, isPending: ${isPending}`
    );
  };

  useEffect(() => {
    savedCallback.current = handleIntersection;
  });

  useEffect(() => {
    const observer = new IntersectionObserver((entries) => {
      savedCallback.current(entries);
    });
    observer.observe(endOfPage.current);
  }, []);

This solution works in all cases - whether you need to read values from the state or not.

How do you use the Intersection Observer API in your app?
Share your thoughts in the comments below!