ReactT Light_870x220

The IntersectionObserver API allows us to track the visibility of HTML elements, but how do you use it within React?

The IntersectionObserver API allows us to detect when an element we are watching is visible on the screen to the user. This may be an oversimplified (even incorrect) definition of how it works, which is technically done by watching when the target element intersects with an ancestor (or the viewport itself), but the reality is that it is easiest understood by thinking in terms of whether or not a specific HTML element is visible to the user.

IntersectionObserver has many uses, and you may want to use this API to:

  • Load additional data when the user scrolls to the end of the screen
  • Track which paragraphs of an article have been read
  • Animate an element the first time it is visible on the screen
  • Track ad or product impressions
  • Play or pause a video when it is visible
  • Lazy-load images as they scroll into view

In this article we will not only see how you could use the IntersectionObserver API to create an infinite scroll page, but also how to track the amount of time each paragraph in an article has been visible to the user. There are other great articles which explain IntersectionObserver well, but this one will specifically focus on how to use it in React.

The final solution and a live demo are available at the previous links.

The Basics of IntersectionObserver

Before we dive into the specifics of how it works in React, let’s see the most basic use of IntersectionObserver. The first step is to create an observer:

const callback = entries => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      console.log(`We are displaying ${entry.target}`);
    }
  });
};
const options = { threshold: 0.5 };
const observer = new IntersectionObserver(callback, options);

Once we have our observer, set it up to be triggered when at least half of the element is visible (threshold: 0.5), we need to tell it which HTML elements to observe:

const target = document.querySelector("#item");
observer.observe(target);

Because the observer can observe multiple elements at a time, the callback always receives multiple entries. This callback is triggered both on entry AND on exit of the element. You can detect this by using the entry.isIntersecting property.

This example found the target element to observe using document.querySelector, but let’s see how to do this more effectively in React using refs.

React and its Many Refs

Refs is a loaded word in React. There is the useRef hook, creating a mutable object with a current property, but this ref doesn’t notify us when changes to it occur. We need to know when an element is rendered for the first time (in order to observe it) or is no longer being rendered (in order to tell our observer to unobserve it).

The second type of ref in React is a reference to an individual HTML element. We can capture this ref using a technique called callback refs. Using this approach, and storing the element’s ref in state, we can use the useEffect hook to react to changes to its value.

function Paragraph({ text }) {
  const [ref, setRef] = React.useState(null);

  React.useEffect(() => {
    if (ref) {
      // Our ref has a value, pointing to an HTML element
      // The perfect time to observe it.
    }

    return () => {
      if (ref) {
        // We need to clean up after this ref
        // The perfect time to unobserve it.
      }
    };
  }, [ref]);

  return <p ref={setRef}>{text}</p>;
}

Infinite Scrolling

We can use an IntersectionObserver to detect when the user has reached the end of the page, triggering another article to be loaded and rendered. Even though it is a little backward (given that this happens at the end of the component), let’s first look at what our component is rendering:

<main>
  <ul>
    {articles.map(article => (
      <li key={article.id}>{/* render article */}</li>
    ))}
  </ul>

  <div ref={setBottom}>loading...</div>
</main>

Now that we know what is being rendered, at the beginning of our component we will set up the state and refs needed for the observer:

const [articles, setArticles] = React.useState([]);
// Will hold a ref to a "bottom" element we will observe
const [bottom, setBottom] = React.useState(null);
// Will hold the IntersectionOberver
const bottomObserver = React.useRef(null);

Next, we can use the useEffect hook to set the bottomObserver, something we only need to happen once, which is why the dependencies of the useEffect hook are empty. The callback function will update the articles state, loading another article using the createArticle function. This only needs to be done if the entry.isIntersecting property is true.

React.useEffect(() => {
  const observer = new IntersectionObserver(
    entries => {
      const entry = entries[0];
      setArticles(articles => {
        if (entry.isIntersecting) {
          return [...articles, createArticle()];
        } else {
          return articles;
        }
      });
    },
    { threshold: 0.25, rootMargin: "50px" }
  );
  bottomObserver.current = observer;
}, []);

Lastly, we can detect when the bottom ref changes, telling our observer to observe or unobserve the element:

React.useEffect(() => {
  const observer = bottomObserver.current;
  if (bottom) {
    observer.observe(bottom);
  }
  return () => {
    if (bottom) {
      observer.unobserve(bottom);
    }
  };
}, [bottom]);

Tracking Impressions

Another valuable use of an IntersectionObserver is to detect when an ad has an “impression”. This is an impression in the truest sense of the word, not that it’s just been rendered, but when it has been visible on the user’s screen. Similar to this, we could track when a product has been displayed, or how long a paragraph has been read (displayed) for.

Starting with the state we need to keep track of the paragraph observer, and the time each paragraph has been displayed, we have:

const [timers, setTimers] = React.useState({});
const paragraphObserver = React.useRef(null);

Let’s see the code to set up the paragraphObserver. Its callback has the job of iterating over the observed entries (paragraphs), and determining if each one should start the timer, meaning it is being displayed, or whether to stop the timer, meaning it is no longer being displayed.

React.useEffect(() => {
  const observer = new IntersectionObserver(
    entries => {
      entries.forEach(entry => {
        setTimers(timers => {
          const id = entry.target.dataset.id;
          const timer = timers[id] || { total: 0, start: null };

          if (entry.isIntersecting) {
            // Start the timer
            timer.start = new Date();
          } else if (timer.start) {
            // Stop the timer and add to the total
            timer.total += new Date().getTime() - timer.start.getTime();
            timer.start = null;
          }

          return { ...timers, [id]: timer };
        });
      });
    },
    { threshold: 0.75 }
  );
  paragraphObserver.current = observer;
}, []);

For a better picture of what is happening, the timer data looks something like:

{
  "para1": { "total": 0, "start": "2019-12-12 10:10:10" },
  "para2": { "total": 25, "start": null },
  "para3": { "total": 0, "start": null }
}

The paragraphs are rendered with the help of a Paragraph component that we’ll see below, passing down the IntersectionObserver instance, allowing it to observe and unobserve the paragraph as it is either rendered for the first time or when it is no longer being rendered.

<main>
  <ul>
    {articles.map(article => (
      <li key={article.id}>
        <h2>{article.title}</h2>

        {article.paragraphs.map((paragraph, i) => {
          const key = `${article.id}|${i}`;
          return (
            <Paragraph
              key={key}
              text={paragraph}
              paragraphId={key}
              observer={paragraphObserver.current}
              timer={timers[key] || { total: 0, start: null }}
            />
          );
        })}
      </li>
    ))}
  </ul>

  <div ref={setBottom}>loading...</div>
</main>

The Paragraph component receives a few props:

  • The text to display
  • A unique paragraphId which will be added to a data attribute
  • An IntersectionObserver instance as observer
  • The timing information for this specific paragraph as timer

Comments have been added in the code to explain the different parts of this component:

function Paragraph({ text, paragraphId, observer, timer }) {
  // Track the ref to the paragraph being rendered
  const [ref, setRef] = React.useState(null);

  // Observe and unobserve this paragraph
  React.useEffect(() => {
    if (ref) {
      observer.observe(ref);
    }
    return () => {
      if (ref) {
        observer.unobserve(ref);
      }
    };
  }, [observer, ref]);

  // Calculate total time displayed for this paragraph
  let total = timer.total;
  // The paragraph is active when it has a start time
  const active = timer.start ? true : false;
  if (active) {
    // If it is still active, add the current time to the previous total
    total += new Date().getTime() - timer.start.getTime();
  }
  // Converting milliseconds to seconds
  const seconds = (total / 1000).toFixed(1);

  // Finally time to render the actual paragraph element
  return (
    <p
      ref={setRef}
      data-id={paragraphId}
      className={active ? "active" : "inactive"}
    >
      <span className="timer">{seconds}s</span>
      {text}
    </p>
  );
}

Conclusion

Using the IntersectionObserver API, we have been able to automatically load the next article and track read-time for each of the paragraphs. We didn’t report it back to the server, but that could be added in order to know on a per-paragraph basis how long they are being read for. We used refs, state and the useEffect hook in order to tie these concepts together in React.


leigh-halliday
About the Author

Leigh Halliday

Leigh Halliday is a full-stack developer specializing in React and Ruby on Rails. He works for FlipGive, writes on his blog, and regularly posts coding tutorials on YouTube.

Related Posts

Comments

Comments are disabled in preview mode.