How To Use an IntersectionObserver in a React Hook

How To Use an IntersectionObserver in a React Hook

Photo by chuttersnap on Unsplash

One of the most difficult things on the web is figuring out if an element is visible or where it is in relation to its parent element. Historically, this meant running calculations triggered by a scroll event, which can quickly become a performance liability for your app.

Luckily, a better and much more performant way to do this has been introduced: the Intersection Observer. The Intersection Observer API allows asynchronous checking of the ratio of the intersection of an element with a viewport and will only fire a callback when the predefined threshold(s) are met. This has opened up many user experiences that were difficult to implement in a performant way, such as infinite scrolling, lazy load images, or delaying animations until visible.

Recently, I wanted to explore how one would implement this in a react hook. I ran into many gotchas, but luckily Dan Abramov recently posted a helpful guide to useEffect over at his blog, Overreacted, which helped me immensely in understanding these gotchas and what I needed to do to fix them. So I thought I would summarize what I learned to help you avoid the same mistakes I encountered.

How Does The Intersection Observer API Work?

To get a complete understanding of the Intersection Observer API, I would recommend that you check out the documentation found at MDN. Simply put, you need to create an Observer that will ‘observe’ a DOM node and call a callback when one or more thresholds are met. A threshold can be any ratio from 0 to 1, where 1 means the element is 100% in the viewport and 0 is 100% out of the viewport. By default, the threshold is set to 0. Here is an example of how to create an observer that I borrowed from MDN:

const callback = (entries, observer) => {
  entries.forEach((entry) => {
    // Each entry describes an intersection change for one observed
    // target element:
    //   entry.boundingClientRect
    //   entry.intersectionRatio
    //   entry.intersectionRect
    //   entry.isIntersecting
    //   entry.rootBounds
    //   entry.target
    //   entry.time
  });
};

const observer = new IntersectionObserver(callBack);

Optionally, You can pass an object as a second parameter to the IntersectionObersver constructor. This object lets you configure the observer. You can configure 3 possible properties: root, rootMargin, and threshold.

The threshold property can either be a single ratio, like 0.2 , or an array of thresholds, like [0.01, 0.02, 0.03,....]. The rootProperty is the element to be used as the viewport when calculating the intersection ratio. The root property must be an ancestor to the observed element and is the browser viewport by default. Finally, you can set the rootMargin property, using the CSS margin syntax to specify an invisible box around the root by which the threshold is calculated.

So the above example could be rewritten like this:

const options = {
  `root: domNode,
  rootMargin: '0px',
  threshold: [0.98, 0.99, 1]`
}


const observer = new IntersectionObserver(callBack, options);

We have the observer, but it’s not yet observing anything. To start it observing, you need to pass a dom node to the observe method. It can observe any number of nodes, but you can only pass in one at a time. When you no longer want it to observe a node, you call the unobserve method and pass it to the node that you would like it to stop watching, or you can call the disconnect method to stop it from observing any node like this:

observer.observer(nodeOne); //observing only nodeOne
observer.observer(nodeTwo); //observing both nodeOne and nodeTwo

observer.unobserve(nodeOne); //observing only nodeTwo

observer.disconnect(); //not observing any node

There are more things, but this covers the most typical use cases of the IntersectionObserver.

How do You Use It in a Hook?

First of all, we need to be able to provide the entry that the IntersectionObserver returns from the callback. To do this, we use the useState hook. We are going to assume that we are only going to be observing one node at a time, so we are going to destructure the entries array into the first entry into the array and save that to state like this:

export const useIntersect = () => {
  const [entry, updateEntry] = useState({});

  const observer = new window.IntersectionObserver(([entry]) =>
    updateEntry(entry)
  );

  return entry;
};

There is already a big gotcha with this. Every time the component rerenders, useIntersect will be called, which means that the observer will be instantiated every time with a new IntersectionObserver. This is not the intended behavior.

What we want to use is the useRef hook. The useRef hook is often used to keep track of a DOM node, so you can do imperative things with it later on (such as give it focus), but useRef can be used to keep any value across rerenders. We access the value of a ref through the current property on the ref itself. The ref itself is mutatable, and that current value can be reassigned anytime, but we will always get back the same ref object with its most recent value on every rerender.

One might ask, what is the difference between useRef and useState since both will return the current value. The biggest difference is how you update the value and what that means to the rest of the components using it. You can only update the state using the second value returned from useState where the ref’s value can be updated anytime by assigning a new value to the current property. Also, updating the value of a ref will not signal a rerender, whereas updating the state will.

Let’s update our hook to use useRef:

export const useIntersect = () => {
  const [entry, updateEntry] = useState({});

  const observer = useRef(
    new window.IntersectionObserver(([entry]) => updateEntry(entry))
  );

  return entry;
};

Our hook is coming together, but we are still missing the most important part: observing. We need two things: a node reference, and we need to start observing it by using the useEffect hook. We might try to implement it like this:

export const useIntersect = () => {
  const [entry, updateEntry] = useState({});
  const node = useRef(null);

  const observer = useRef(
    new window.IntersectionObserver(([entry]) => updateEntry(entry))
  );

  useEffect(() => {
    observer.current.observe(node.current);
    return () => observer.current.disconnect();
  });

  return [node, entry];
};

This already has many gotchas. The first gotcha wasn’t obvious until I read more about how the useEffect hook worked. The function you return from the useEffect hook is run when the component is unmounting. That way, you can clean up things like disconnecting the observer, which is what we are doing. The gotcha is that since current it can be mutated, it’s unsafe to access the current property directly. If, for some reason, in the future, I decide to reassign the current property to something else, like null or another observer, then the cleanup function may not clean up as I expect. The safe thing to do is to assign the current property to a variable in the useEffect hook and then use the variable instead of the current property directly. Like this:

export const useIntersect = () => {
  const [entry, updateEntry] = useState({});
  const node = useRef(null);

  const observer = useRef(
    new window.IntersectionObserver(([entry]) => updateEntry(entry))
  );

  useEffect(() => {
    const { current: currentObserver } = observer;

    currentObserver.observe(node.current);

    return () => currentObserver.disconnect();
  });

  return [node, entry];
};

The other gotcha is that this effect will run over and over again because when the observer calls the callback, it will update the state, which will cause a rerender, which will cause the useEffect hook to be run again. The first thought would be to pass node in the dependency array for useEffect. The problem is that passing the ref has its own ‘lifecycle’ and cannot reliably tell the useEffect hook to skip running this render.

To fix this, we need to switch from using the useRef hook to the useState hook like this:

export const useIntersect = () => {
  const [entry, updateEntry] = useState({});
  const [node, setNode] = useState(null);

  const observer = useRef(
    new window.IntersectionObserver(([entry]) => updateEntry(entry))
  );

  useEffect(() => {
    const { current: currentObserver } = observer;

    if (node) currentObserver.observe(node);

    return () => currentObserver.disconnect();
  });

  return [setNode, entry];
};

By passing the setNode function, we are using the callback ref pattern instead of the new ref pattern (to learn more about the various ways to handle refs, you can read my beginner’s guide to refs in react post). This will pass the node into the callback we provide, which in our case, will update the state to be the new node. This does mean that on the first pass, the node will be null, so we need to do a check to make sure the node has a value before we attempt to ‘observe’ it.

Things are looking great, but there are two more things that we need to consider. First, what happens if the component using the hook changes the node that the observer is ‘observing’? It will trigger a state change since the setRef will be called on the new node. Since the node has changed, the useEffect will run again, and it will start observing the new node. Sounds good, right? What about the old node? You're right; we never stopped observing it. This means that there will be more than one entry in the callback, and we may be saving the wrong entry to the state, not to mention the fact that we are observing nodes that we no longer care about.

To fix this, we need to disconnect the observer every time useEffect is called like this:

export const useIntersect = () => {
  const [entry, updateEntry] = useState({});
  const [node, setNode] = useState(null);

  const observer = useRef(
    new window.IntersectionObserver(([entry]) => updateEntry(entry))
  );

  useEffect(() => {
    const { current: currentObserver } = observer;

    if (node) currentObserver.observe(node);

    return () => currentObserver.disconnect();
  }, [node]);

  return [setNode, entry];
};

This will ensure that the observer only looks at the node we care about. The last thing our hook needs is to be able to customize our observer. We can pass the config object into the hook. We can provide some default values to the options so that we provide some safety to the way our hook runs. Here is the final version of our hook:

export const useIntersect = ({ root = null, rootMargin, threshold = 0 }) => {
  const [entry, updateEntry] = useState({});
  const [node, setNode] = useState(null);

  const observer = useRef(
    new window.IntersectionObserver(([entry]) => updateEntry(entry), {
      root,
      rootMargin,
      threshold,
    })
  );

  useEffect(() => {
    const { current: currentObserver } = observer;
    currentObserver.disconnect();

    if (node) currentObserver.observe(node);

    return () => currentObserver.disconnect();
  }, [node]);

  return [setNode, entry];
};

Now we have our fully functional custom hook, let's see it in action. I created a code sandbox using our new useIntersect hook. You can look through the code and see how it works, but basically, I have several boxes; every other one will either fade or grow based on its intersection ratio. You can toggle each box into a fade or grow box, and the hook properly accounts for the node changes.

Hooks offer a great way to compose functionality, like an Intersection Observer, into a component. Once you understand how it works, you can do some amazing things.


I have made an update based on the comments by Porfírio Ribeiro. There were two more gotchas that I didn’t catch before. The first gotcha is that the hook doesn’t update the observer if any config values change. The initial values are the only ones respected on the first render and are effectively ignored for every other render.

The other gotcha is related to the first one, which is that useRef will use whatever is passed in as the initial value the first time it’s called but will ignore it every other render. If it was a simple primitive value, that is no big deal, but we are constructing a new IntersectionObserver object for every render, even though it is being ignored on all subsequent renders.

Luckily we can solve both problems at once by moving the construction of the IntersectionObserver to the useEffect function like this:

export const useIntersect = ({ root = null, rootMargin, threshold = 0 }) => {
  const [entry, updateEntry] = useState({});
  const [node, setNode] = useState(null);

  const observer = useRef(null);

  useEffect(() => {
    if (observer.current) observer.current.disconnect();

    observer.current = new window.IntersectionObserver(
      ([entry]) => updateEntry(entry),
      {
        root,
        rootMargin,
        threshold,
      }
    );

    const { current: currentObserver } = observer;

    if (node) currentObserver.observe(node);

    return () => currentObserver.disconnect();
  }, [node, root, rootMargin, threshold]);

  return [setNode, entry];
};

As you can see, now that the observer could be null on the first render, we are only disconnecting if the current value is not null. We are then constructing the IntersectionObserver and assigning it to the current. After that, the hook works exactly the same. This allows us to add the config options as dependencies.

It is important to note that since the threshold can be an array, it is important that the array passed in is the exact array every time. That is because arrays pass by reference and useEffect will only check if the reference changed to determine if it `should re-run the useEffect callback. In the code sandbox, I use useMemo with no dependencies to ensure that the array passed in is the same every time.

Did you find this article valuable?

Support Travis Waith-Mair by becoming a sponsor. Any amount is appreciated!