How to detect changes in component visibility when scrolling?

Amaljith K

By Amaljith K

on September 27, 2022

When there is a need to display large set of data, most of the web applications split whole set into several smaller chunks and then serve on demand. This technique is called pagination.

Earlier, pagination looked like this: image Here, loading next set of data required user to click on next page button.

These days, we use infinite scroll technique which automatically loads next set of data when user scrolls to the bottom of the list. This is more user friendly: image

Several JS libraries are available to facilitate infinite scroll. But to quench our curiosity about how things work under the hood, it is best to try to implement it from the scratch.

To implement infinite scroll, we need to know when the user has scrolled to the bottom of the list to load the next page's data. To know if the user has reached the bottom, we can watch the last element of the list. That is, when the list is scrolled and the last element becomes visible, we know that we are at the bottom.

Detecting the visibility of elements during scroll was a hard problem until recently. We had to hook onto onscroll events of the element and check the boundaries of the elements using getBoundingClientRect function.

Since onscroll event gets fired around 40-50 times per second, performing the operations inside it will become expensive. Moreover, the onscroll function gets executed from the main UI thread. All these together make our web application sluggish.

But now, we have a much more performant alternative for this problem. All popular web browsers support a new API named IntersectionObserver from 2019 onwards.

The advantages of IntersectionObserver API are:

  • It doesn't grab the resources from the UI thread. It accepts a callback function that will be fired asynchronously.
  • The supplied callback is triggered only when a change in visibility is detected. We can save 40-50 repetitions per second during the scroll.
  • We don't need to worry about maintaining boilerplate code for detecting the boundaries & calculating the visibility. We get all useful data as a parameter to the callback function.

The introduction of IntersectionObserver simplified a whole set of requirements like:

  • Infinite loading.
  • Improving page load time by not fetching resources (like images) that aren't visible until the user scrolls to it. This is called lazy loading.
  • Track whether the user has scrolled to and viewed an ad posted on the web page.
  • UX improvements like dimming animation for the components that aren't fully visible.

In this blog, we are going to discuss how we can use IntersectionObserver in a React application as hooks.

Creating a hook for detecting visibility changes

We will create a custom hook that will update whenever the specified component scrolls into view and scrolls out of view. Let us name the hook useIsElementVisible. Obviously, it will accept a reference to the component of which visibility need to be monitored as its argument.

It will have a state to store the visibility status of the specified element. It will have a useEffect hook from which we will bind the IntersectionObserver to the specified component.

Here is the basic implementation:

1import { useEffect, useState } from "react";
3const useIsElementVisible = target => {
4  const [isVisible, setIsVisible] = useState(false); // store visibility status
6  useEffect(() => {
7    // bind IntersectionObserver to the target element
8    const observer = new IntersectionObserver(onVisibilityChange);
9    observer.observe(target);
10  }, [target]);
12  // handle visibility changes
13  const onVisibilityChange = entries => setIsVisible(entries[0].isIntersecting);
15  return isVisible;
18export default useIsElementVisible;

We can use useIsElementVisible like this:

1const ListItem = () => {
2  const elementRef = useRef(null); // to hold reference to the component we need to track
4  const isElementVisible = useIsElementVisible(elementRef.current);
6  return (
7    <div ref={elementRef} id="list-item">
8      {/* your component jsx */}
9    </div>
10  );

The component ListItem will get updated whenever the user has scrolled to see the div "list-item". We can use the value of isElementVisible to load the contents of the next page from a useEffect hook:

1useEffect(() => {
2  if (isElementVisible && nextPageNotLoadedYet()) {
3    loadNextPage();
4  }
5}, [isElementVisible]);

This works in theory. But if you try it, you will notice that this doesn't work as expected. We missed an edge case.

The real-life edge case

We use a useRef hook for referencing the div. During the initial render, elementRef was just initialized with null as its value. So, elementRef.current will be null and as a result, the call useIsElementVisible(elementRef.current) won't attach our observer with the element for the first time.

Unfortunately, useRef hook won't re-render when a value is set to it after DOM is prepared. Also, there are no state updates or anything that requests a re-render inside our example component. In short, our component will render only once.

With these in place, useIsElementVisible will never get reference to the "list-item" div in our previous example.

But there is a workaround for our problem. We can force render the component twice during the first mount.

To make it possible, we will add a dummy state. When our hook is called for the first time (when ListItem mounts), we will update our state once, thereby requesting React to repeat the component render steps again. During the second render, we will already have our DOM ready and we will have the target element attached to elementRef.

Force re-rendering the component

To keep our code clean and modular, let us create a dedicated custom hook for managing force re-renders:

1import { useState } from "react";
3const useForceRerender = () => {
4  const [, setValue] = useState(0); // we don't need the value of this state.
5  return () => setValue(value => value + 1);
8export default useForceRerender;

Now, we can use it in our useIsElementVisible hook this way:

1const useIsElementVisible = target => {
2  const [isVisible, setIsVisible] = useState(false);
4  const forceRerender = useForceRerender();
6  useEffect(() => {
7    forceRerender();
8  }, []);
10  // previous code to register observer
12  return isIntersecting;

With this change, our hook is now self-sufficient and fully functional. In our ListItem component, isElementVisible will update to false and trigger component re-render whenever our "list-item" div goes outside visible zone during scroll. It will also update to true when it is scrolled into visibility again.

Possible improvements on useIsElementVisible hook

The useIsElementVisible hook shown in the previous sections serves only the basic use case. It is not optimal to use in a production world.

These are the scopes for improvement for our hook:

  • We can let in configurations for IntersectionObserver to customize its behavior.
  • We can prevent initializing observer when the target is not ready yet (when it is null).
  • We can add a cleanup function to stop observing the element when our component gets unmounted.

Here is what the optimum code for the hook should look like:

1import { useEffect, useState } from "react";
3export const useForceRerender = () => {
4  const [, setValue] = useState(0); // we don't need the value of this state.
5  return () => setValue(value => value + 1);
8export const useIsElementVisible = (target, options = undefined) => {
9  const [isVisible, setIsVisible] = useState(false);
10  const forceUpdate = useForceRerender();
12  useEffect(() => {
13    forceUpdate(); // to ensure that ref.current is attached to the DOM element
14  }, []);
16  useEffect(() => {
17    if (!target) return;
19    const observer = new IntersectionObserver(handleVisibilityChange, options);
20    observer.observe(target);
22    return () => observer.unobserve(target);
23  }, [target, options]);
25  const handleVisibilityChange = ([entry]) =>
26    setIsVisible(entry.isIntersecting);
28  return isVisible;