Using Intersection Observer API in React

Introduction

There are different reasons to perform some functionalities or animation effects when a user scrolls on a web page. Examples of this could be to toggle a class, to conditionally render different components based on scroll or a certain element position, or to animate elements in and out of the viewport. There are also several ways to get this done; we can use scroll event listeners, scroll animation libraries, or Javascript's Intersection Observer API.

In this article, we will explore the benefits and drawbacks of these approaches and also go on to discuss further the Intersection Observer API and how it can be used in React.

Prerequisites

Before we proceed, it is important to note that a basic knowledge of HTML, CSS, JS, and React is crucial to fully understanding the concepts discussed in this article. That being said, let's get into it!

Scroll Event Listeners

If you want to make an event happen on Scroll, it is probably a no-brainer to go with a Scroll event listener, as that will give you exactly what you want... initially. But as I'm sure there will be other edge cases to consider in a real-life project, you'll soon realize that this brings even more trouble because this said event would then happen every time the user scrolls on the webpage. Unless in specific use cases, this will ultimately become annoying to the user and eventually lead to some issues, such as;

  • Performance: Scroll event listeners trigger an event every time the user scrolls the page, which can lead to a high number of events being fired. Handling these events in real-time can be computationally expensive and cause performance issues, especially on complex web pages or on devices with limited resources.

  • Jank and Stuttering: When scroll event listeners are used to update elements on the page, such as animating or manipulating DOM elements, it can result in janky and stuttering scrolling experiences. This occurs because the scroll events are processed in the main thread, potentially blocking other critical operations.

  • Lack of Control: Scroll events fire continuously while scrolling, even if you're only interested in specific points or sections on the page. This can make it challenging to optimize code or trigger actions at specific scroll positions.

Scroll Animation Libraries

Animation is one of the most common usages of scroll events. Having elements fade in and fade out, changing shapes and styles as the user scrolls through the webpage, has been a game changer in designing aesthetically pleasing websites.

My personal top two animation libraries for React are Framer Motion and GSAP. These libraries are hands down the best out there right now, in my opinion, and are more than capable of bringing wild creative imaginations to life.

But most times, it is just best to really keep simple things simple. Sure using animation libraries may be fancy and whatnot, but if what you're trying to do is straightforward and simple enough, I see no reason to bloat up your code base with another library when you can achieve the same thing with a built-in Javascript API.

Intersection Observer API

While working on an open-source project I'm contributing to over the past week, there was a need to implement the design of a component that changes state based on its position in the viewport. I knew that I didn't want the state change to happen just on scroll, and I also did not want to install any third-party package unless it was absolutely necessary. After much research and trial, Intersection Observer API gave me the perfect solution I was looking for, which was the main motivation behind this article.

The IntersectionObserver() is a JavaScript API designed specifically for efficiently observing changes in the intersection of an element with its parent container or the viewport. Apart from the already mentioned need to keep things simple, it also offers the following improvements over scroll event listeners:

  • Performance Optimization: Intersection Observer is designed to optimize performance by providing a callback that executes when elements enter or exit the viewport or another defined area. This approach reduces the number of events fired and allows the browser to handle the observations more efficiently.

  • Asynchronous Execution: The scrolling and layout operations are separated from the JavaScript execution via the Intersection Observer API's asynchronous operation. This division eliminates jank and stuttering, resulting in a more fluid scrolling experience.

  • Granular Control: You can provide particular targets and thresholds for observing element visibility with Intersection Observer's second parameter, which is an options object. This gives you more control and flexibility by allowing you to selectively watch and respond to changes in particular parts or sections of a page.

  • Better Resource Management: Intersection Observer helps to optimize the use of system resources by keeping track of element visibility. It enables you to save bandwidth and enhance efficiency by allowing you to load or unload content, lazy load images, or activate animations only when necessary.

Enough talk. Show me the code!

Now we are going to build a simple project just to demonstrate how the Intersection Observer API works and how we can use it within a React project. The aim of this project is simply to change the styles of the navigation bar and also make it stick to the top of the page when the user scrolls.

We start by bootstrapping a React project with Vite by navigating to a preferred directory and running the following commands:

npm create vite@latest
// select Typescript as the preferred language, and
// specify the project name, here it is react-intersection-observer 

cd react-intersection-observer

npm install

npm run dev

After running these commands, we should have our React project running in the browser

Go ahead and delete index.css file as we won't be needing it and in App.css, add the following code:

* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

#root {
  font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
  text-align: center;
}

.sentinel {
  background-color: skyblue;
  height: 5px;
}

.navbar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  background-color: skyblue;
  padding: 2rem;
}

.stuck {
  position: sticky;
  top: 0px;
  box-shadow: rgba(50, 50, 93, 0.25) 0px 50px 100px -20px, rgba(0, 0, 0, 0.3) 0px 30px 60px -30px;
  color: #fff;
  transition: all 0.3s;
}

.container {
  display: flex;
  flex-direction: column;
  align-items: center;
}

section {
  width: 90%;
  box-shadow: rgba(50, 50, 93, 0.25) 0px 6px 12px -2px, rgba(0, 0, 0, 0.3) 0px 3px 7px -3px;
  border-radius: 10px;
  padding: 50px;
  margin: 50px 0;
}

.title {
  font-size: 2rem;
  margin-bottom: 2rem;
}

.content {
  color: #999ea9;
  line-height: 2rem;
  text-align: justify;
}

footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 2rem;
  color: #fff;
  background-color: skyblue;
}

@media screen and (min-width: 768px) {
  section {
    width: 60%;
  }
}

The above style declarations contain all the styling required for the finished project. Nothing fancy going on, just basic CSS to make everything look clean.

In main.tsx, remove the import for index.css and leave everything else as is.

Now, in the App.tsx, this is where everything comes together. Go ahead and replace everything with the following code:

import { useEffect, useRef, useState } from 'react';
import './App.css';

function App() {
  const [intersecting, setIntersecting] = useState<boolean>(true);
  const sentinelRef = useRef<HTMLDivElement | null>(null);
  const navRef = useRef<HTMLDivElement | null>(null);
  const observerOptions = {
    root: null,
    rootMargin: '0px',
    treshold: 1.0
  };

  useEffect(() => {
    const observer = new IntersectionObserver(([entry]) => {
      if (!entry.isIntersecting) {
        setIntersecting(true);
        navRef.current?.classList.add('stuck');
      } else {
        setIntersecting(false);
        navRef.current?.classList.remove('stuck');
      }
    }, observerOptions);

    if (sentinelRef.current !== null) {
      observer.observe(sentinelRef.current);
    }
  });

  return (
    <main>
      <div className="sentinel" ref={sentinelRef}></div>

      <nav ref={navRef} className="navbar">
        <h2>Hashnode Articles</h2>
        <h3>Emmanuel Oloke</h3>
      </nav>

      <div className="container">
        <section>
          <h1 className="title">Using Intersection Observer API in React</h1>
          <article className="content">
            There are different reasons to perform some functionalities or animation effects when a
            user scrolls on a web page. Examples of this could be to toggle a class, to
            conditionally render different components based on scroll or a certain element position,
            or to animate elements in and out of the viewport. There are also various ways to get
            these done; we can use scroll event listeners, scroll animation libraries, or
            Javascript's IntersectionObserver API. In this article, we will explore the benefits and
            drawbacks of these approaches and also go on to discuss further the IntersectionObserver
            API and how it can be used in React...
          </article>
        </section>

        <section>
          <h1 className="title">Building Charts with React and ChartJS</h1>
          <article className="content">
            Data visualization tools are powerful for analyzing and communicating complex data sets
            in a more accessible and intuitive way. With the advent of modern web technologies,
            creating interactive data visualizations has become easier than ever before. React and
            Chart.js are two popular technologies developers can use to create dynamic and
            interactive data visualizations...
          </article>
        </section>

        <section>
          <h1 className="title">useEffect vs useSWR</h1>
          <article className="content">
            The concept of data fetching in React applications is of high importance as it is often
            necessary to fetch data from an external source, such as an API or a database, and use
            that data to render components. React provides several ways to fetch data, including the
            built-in fetch method and popular third-party library Axios. One popular approach to
            data fetching in React is to use hooks like useEffect and useSWR from the swr
            third-party npm package. These hooks allow developers to fetch data and manage its state
            within a component, making it easy to update the UI in response to changes in the
            data...
          </article>
        </section>
      </div>
      <footer>
        <h2>Emmanuel Oloke</h2>
        <h3>&copy; 2023, All rights reserved</h3>
      </footer>
    </main>
  );
}

export default App;

Now to fully explain how everything works and bring it all together. To use the Intersection Observer API in React, we turn to our good old friends useState, useEffect, and useRef hooks.

We first declare a state variable called intersecting and the typical setter function as setIntersecting and initialize it to a boolean value of true. This is the variable that will hold the information on whether or not the nav element is intersecting so we can perform whatever function we want to, be it adding or removing a CSS class as we have done here or performing some other functions.

Next, we have the sentinelRef which is an HTMLDivElement. The purpose of this sentinel element is to stand as a guard just above the nav element, and then we use the Intersection Observer to observe and alert us once it leaves or enters the viewport so we can perform the desired actions on the nav element based on that information.

The navRef variable declared as an HTMLDivElement is used to select the nav element from the document, the same way you would do document.querySelector('nav') in vanilla Javascript. With this, we have access to the nav element, and we can now access the classList property and add or remove a class from the element.

The Intersection Observer API takes in a callback function and an options object. This options object passed into the IntersectionObserver() constructor lets you control the circumstances under which the observer's callback is invoked. It has the following fields:

  • root: The element that is used as the viewport for checking the visibility of the target. It must be the ancestor of the target. It defaults to the browser's viewport if not specified or if null.

  • rootMargin: This refers to the margin around the root. It can have values similar to the CSS margin property, e.g. "10px 20px 30px 40px" (top, right, bottom, left). These values can also be in percentages. This set of values serves to grow or shrink each side of the root element's bounding box before computing intersections. It is all zeros by default.

  • threshold: Either a single number or an array of numbers that indicate at what percentage of the target's visibility the observer's callback should be executed. If you only want to detect when visibility passes the 50% mark, you can use a value of 0.5. If you want the callback to run every time visibility passes another 25%, you would specify the array [0, 0.25, 0.5, 0.75, 1]. The default is 0 (meaning as soon as even one pixel is visible, the callback will be run). A value of 1.0 means that the threshold isn't considered passed until every pixel is visible.

With all the declarations taken care of, we can now go ahead and use them. To do this, we use React's useEffect hook to watch the intersecting state variable and perform the callback function every time it changes.

In the useEffect, we declare a variable called observer and assign the IntersectionObserver constructor to it. Inside the constructor, we destructure out the entry object in order to gain access to its isIntersecting property. To visualize this, if we console.log the entry object this is what we get back:

Then we proceed to check, if (!entry.isIntersecting). That is if the element we are observing, which in this case will be the sentinelRef is not intersecting (no longer in the viewport). Then we want to set intersecting state variable to true and add the stuck class to the nav element. Else, we set intersecting state variable to false and remove the stuck class. As the second parameter for the IntersectionObserver constructor, we pass in the observerOptions already declared. I mostly used default values which have been explained here as nothing too fancy is going on.

In essence, that is all the logic we need. We are watching for an element to enter or leave the default or specified viewport and performing an operation based on that fact.

Finally, to invoke the observer constructor, we access the observe property on it and pass in the element we want to observe. Which, in this case, is the sentinelRef which points the sentinel element above the nav in the DOM.

The remaining parts of the code are just dummy content to flesh out the page.

And with that, our project now looks like this in the browser:

Conclusion

Although there are several tools and libraries out there that can do more sophisticated animations on scroll. But as I said earlier in the article, sometimes it's nice to keep the simple things simple and not have to rely on libraries all the time.

I hope you find this helpful. Do let me know in the comment section, and don't hesitate to like and share as you will.

Here is a link to the live version of the project we just built: Live Project

And also the GitHub repo for the code.

Thanks for reading up to this point. Until next time, cheers!🥂

References