DEV Community

loading...

Building Infinite scroll in React with hooks and Intersection Observer

somtougeh profile image Somto M.Ugeh ・10 min read

Knowing what is visible and not visible on a webpage can be very useful information. You can lazy-load images when they come into view, stop videos when they go out of view, even get proper analytics regarding how many content users read on your blog. However, this is usually a difficult thing to implement. Historically, there was no dedicated API for this and one had to find some other means (e.g. Element.getBoundingClientRect()) for workarounds which can negatively affect the performance of our applications.

Introducing: Intersection Observer API

A better performant way to achieve our goal. The Intersection Observer API is a browser API that can be used to track the position of HTML elements in context to the actual viewport of the browser. The official documentation says: "The Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document's viewport." — MDN

I wanted to explore how I could implement infinite scroll with react using Intersection Observer. I thought I should summarize what I have learned to hopefully help you avoid the same mistakes I ran into.

It is important that you are comfortable using React's ref API because it is applied to enable the connection between DOM nodes and the intersection observer in React. Otherwise React is a declarative view layer library where it is not planned to access DOM nodes.

How Does The Intersection Observer API Work?

In order to get a complete understanding of the Intersection Observer API, I would recommend that you check out the documentation found at MDN.

Intersection Observers work in two parts: an observer instance attached to either a specific node or to the overall viewport and a request to this observer to monitor specific children within its descendants. When the observer is created, it is also provided with a callback that receives one or more intersection entries.

Simply put, you need to create an Observer that will ‘observe’ a DOM node and execute a callback when one or more of its threshold options 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.

// Example from MDN

let options = {
  root: document.querySelector('#scrollArea') || null, // page as root
  rootMargin: '0px',
  threshold: 1.0
}

let observer = new IntersectionObserver(callback, options);

/* 
   options let you control the circumstances under which
   the observer's callback is invoked
*/
Enter fullscreen mode Exit fullscreen mode

Once you have created your observer, you have to give it a target element to watch:

let target = document.querySelector('#listItem');
observer.observe(target);
Enter fullscreen mode Exit fullscreen mode

Whenever the target meets a threshold specified for the IntersectionObserver, the callback is invoked. The callback receives a list of IntersectionObserverEntry objects and the observer:

let 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
  });


 console.log(entries, observer)
};
Enter fullscreen mode Exit fullscreen mode

The Threshold

The threshold refers to how much of an intersection has been observed in relation to the root of the IntersectionObserver

Let's consider this image below:
The threshold for page A is 25%, B is 50% while C is 75%

The first thing to do is to declare the page/scroll area as our root. We can then consider the image container as our target. Scrolling the target into the root gives us different thresholds. The threshold can either be a single item, like 0.2, or an array of thresholds, like [0.1, 0.2, 0.3, ...]. It is important to note that the root property must be an ancestor to the element being observed and is the browser viewport by default.

let options = {
  root: document.querySelector('#scrollArea'), 
  rootMargin: '0px',
  threshold: [0.98, 0.99, 1]
}

let observer = new IntersectionObserver(callback, options);
Enter fullscreen mode Exit fullscreen mode

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 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:

let target = document.querySelector('#listItem');
observer.observe(target);

observer.unobserve(target);
//observing only target

observer.disconnect(); 
//not observing any node
Enter fullscreen mode Exit fullscreen mode

React

We are going to be implementing Intersection observer by creating an infinite scroll for a list of images. We will be making use of the super easy . It's a great choice because it is paginated.

NB: You should know how to fetch data using hooks, if you are not familiar, you can check out this article. Good stuff there!

import React, { useState, useEffect, useCallback } from 'react';
import axios from 'axios';

export default function App() {
  const [loading, setLoading] = useState(false);
  const [images, setImages] = useState([]);
  const [page, setPage] = useState(1);


  const fetchData = useCallback(async pageNumber => {
    const url = `https://picsum.photos/v2/list?page=${page}&limit=15`;
    setLoading(true);

    try {
      const res = await axios.get(url);
      const { status, data } = res;

      setLoading(false);
      return { status, data };
    } catch (e) {
      setLoading(false);
      return e;
    }
  }, []);

  const handleInitial = useCallback(async page => {
      const newImages = await fetchData(page);
      const { status, data } = newImages;
      if (status === 200) setImages(images => [...images, ...data]);
    },
    [fetchData]
  );

  useEffect(() => {
    handleInitial(page);
  }, [handleInitial]);

  return (
      <div className="appStyle">

      {images && (
        <ul className="imageGrid">
          {images.map((image, index) => (
            <li key={index} className="imageContainer">
              <img src={image.download_url} alt={image.author} className="imageStyle" />
            </li>
          ))}
        </ul>
      )}

      {loading && <li>Loading ...</li>}

      <div className="buttonContainer">
        <button className="buttonStyle">Load More</button>
      </div>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

This is the core of the app. We want to be able to load up the page and have it make API call to the Lorem Picsum API and then display some images.

This is a good first step as we have been able to handle data fetching. The next thing to do is thinking of how we can write code to make more requests and update the image lists we have stored in our state. To do this, we have to create a function that will take in the current page and then increase it by 1. This should then trigger the useEffect() to make a call for us and update the UI.

// const [page, setPage] = useState(1);
const loadMore = () => {
    setPage(page => page + 1);
    handleInitial(page);
};
Enter fullscreen mode Exit fullscreen mode

Great, we have written our updater function. We can attach this to a button on the screen and have it make the calls for us!

<div className="buttonContainer">
   <button className="buttonStyle" onClick={loadMore}>Load More</button>
</div>
Enter fullscreen mode Exit fullscreen mode

Open up your network tab to be sure that this is working. If you checked properly, you would see that when we click on Load More, it actually works. The only problem is, it is reading the updated value of page as 1. This is interesting, you might be wondering why that is so. The simple answer is, we are still in a function scope when the update is made and we do not have access to the updated state until the function finishes executing. This is unlike setState() where you had a callback available.

Ok, so how do we solve this. We will be making use of react useRef() hook. useRef() returns an object that has a current attribute pointing to the item you are referencing.

import React, { useRef } from "react";

const Game = () => {
  const gameRef = useRef(1);
};

const increaseGame = () => {
  gameRef.current; // this is how to access the current item
  gameRef.current++;

  console.log(gameRef); // 2, update made while in the function scope.
} 
Enter fullscreen mode Exit fullscreen mode

This approach will help us properly handle the data fetching in our application.

// Instead of const [page, setPage] = useState(1);
const page = useRef(1);

const loadMore = () => {
  page.current++;
  handleInitial(page);
};

useEffect(() => {
   handleInitial(page);
}, [handleInitial]);
Enter fullscreen mode Exit fullscreen mode

Now, if you hit the Load More button, it should behave as expected. Yay! 🎉. We can consider the first part of this article done. Now to the main business, how can we take what we have learned about Intersection Observer and apply it to this app?

The first thing to consider is the approach. Using the illustration explaining the threshold above, we will like to load images once the Load More button comes into view. We can have the threshold set at 1 or 0.75. We have to set up Intersection Observer in React.

// create a variable called observer and initialize the IntersectionObserver()
const observer = useRef(new IntersectionObserver());

/*

A couple of things you can pass to IntersectionObserver() ... 
the first is a callback function, that will be called every time
the elements you are observing is shown on the screen, 
the next are some options for the observer

*/

const observer = useRef(new IntersectionObserver(entries => {}, options)
Enter fullscreen mode Exit fullscreen mode

By doing this we have initialized the IntersectionObserver(). However, initializing is not enough. React needs to know to observe or unobserve. To do this, we will be making use of the useEffect() hook. Lets also set the threshold to 1.

// Threshold set to 1
const observer = useRef(new IntersectionObserver(entries => {}, { threshold: 1 })

useEffect(() => {
  const currentObserver = observer.current;
    // This creates a copy of the observer 
  currentObserver.observe(); 
}, []);
Enter fullscreen mode Exit fullscreen mode

We need to pass an element for the observer to observe. In our case, we want to observe the Load More button. The best approach to this creates a ref and pass it to the observer function.

// we need to set an element for the observer to observer
const [element, setElement] = useState(null);

<div ref={setElement} className="buttonContainer">
  <button className="buttonStyle">Load More</button>
</div>

/*

on page load, this will trigger and set the element in state to itself, 
the idea is you want to run code on change to this element, so you 
will need this to make us of `useEffect()`

*/
Enter fullscreen mode Exit fullscreen mode

So we can now update our observer function to include the element we want to observe

useEffect(() => {
  const currentElement = element; // create a copy of the element from state
  const currentObserver = observer.current;

  if (currentElement) {
    // check if element exists to avoid errors
    currentObserver.observe(currentElement);
  }
}, [element]);
Enter fullscreen mode Exit fullscreen mode

The last thing is to set up a cleanup function in our useEffect() that will unobserve() as the components unmount.

useEffect(() => {
  const currentElement = element; 
  const currentObserver = observer.current; 

  if (currentElement) {
    currentObserver.observe(currentElement); 
  }

  return () => {
    if (currentElement) {
      // check if element exists and stop watching
      currentObserver.unobserve(currentElement);
    }
  };
}, [element]);
Enter fullscreen mode Exit fullscreen mode

If we take a look at the webpage, it still doesn't seem like anything has changed. Well, that is because we need to do something with the initialized IntersectionObserver().

const observer = useRef(
  new IntersectionObserver(
    entries => {},
    { threshold: 1 }
  )
);

/*

entries is an array of items you can watch using the `IntersectionObserver()`,
since we only have one item we are watching, we can use bracket notation to
get the first element in the entries array

*/

const observer = useRef(
  new IntersectionObserver(
    entries => {
      const firstEntry = entries[0];
      console.log(firstEntry); // check out the info from the console.log()
    },
    { threshold: 1 }
  )
);
Enter fullscreen mode Exit fullscreen mode

From the console.log(), we can see the object available to each item we are watching. You should pay attention to the isIntersecting, if you scroll the Load More button into view, it changes to true and updates to false when not in-view.

const observer = useRef(
  new IntersectionObserver(
    entries => {
      const firstEntry = entries[0];
      console.log(firstEntry);

      if (firstEntry.isIntersecting) {
        loadMore(); // loadMore if item is in-view
      }
    },
    { threshold: 1 }
  )
);
Enter fullscreen mode Exit fullscreen mode

This works for us, you should check the webpage and as you scroll approaching the Load More button, it triggers the loadMore(). This has a bug in it though, if you scroll up and down, isIntersecting will be set to false then true. You don't want to load more images when you anytime you scroll up and then down again.

To make this work properly, we will be making use of the boundingClientRect object available to the item we are watching.

const observer = useRef(
    new IntersectionObserver(
      entries => {
        const firstEntry = entries[0];
        const y = firstEntry.boundingClientRect.y;
        console.log(y); 
      },
      { threshold: 1 }
    )
  );
Enter fullscreen mode Exit fullscreen mode

We are interested in the position of the Load More button on the page. We want a way to check if the position has changed and if the current position is greater than the previous position.

const initialY = useRef(0); // default position holder

const observer = useRef(
  new IntersectionObserver(
    entries => {
      const firstEntry = entries[0];
      const y = firstEntry.boundingClientRect.y;

            console.log(prevY.current, y); // check

      if (initialY.current > y) {
                console.log("changed") // loadMore()
      }

      initialY.current = y; // updated the current position
    },
    { threshold: 1 }
  )
);
Enter fullscreen mode Exit fullscreen mode

With this update, when you scroll, it should load more images and its fine if you scroll up and down within content already available.

Full Code

import React, { useState, useEffect, useCallback, useRef } from 'react';
import axios from 'axios';

export default function App() {
  const [element, setElement] = useState(null);
  const [loading, setLoading] = useState(false);
  const [images, setImages] = useState([]);

  const page = useRef(1);
  const prevY = useRef(0);
  const observer = useRef(
    new IntersectionObserver(
      entries => {
        const firstEntry = entries[0];
        const y = firstEntry.boundingClientRect.y;

        if (prevY.current > y) {
          setTimeout(() => loadMore(), 1000); // 1 sec delay
        }

        prevY.current = y;
      },
      { threshold: 1 }
    )
  );

  const fetchData = useCallback(async pageNumber => {
    const url = `https://picsum.photos/v2/list?page=${pageNumber}&limit=15`;
    setLoading(true);

    try {
      const res = await axios.get(url);
      const { status, data } = res;

      setLoading(false);
      return { status, data };
    } catch (e) {
      setLoading(false);
      return e;
    }
  }, []);

  const handleInitial = useCallback(
    async page => {
      const newImages = await fetchData(page);
      const { status, data } = newImages;
      if (status === 200) setImages(images => [...images, ...data]);
    },
    [fetchData]
  );

  const loadMore = () => {
    page.current++;
    handleInitial(page.current);
  };

  useEffect(() => {
    handleInitial(page.current);
  }, [handleInitial]);

  useEffect(() => {
    const currentElement = element;
    const currentObserver = observer.current;

    if (currentElement) {
      currentObserver.observe(currentElement);
    }

    return () => {
      if (currentElement) {
        currentObserver.unobserve(currentElement);
      }
    };
  }, [element]);

  return (
    <div className="appStyle">
      {images && (
        <ul className="imageGrid">
          {images.map((image, index) => (
            <li key={index} className="imageContainer">
              <img src={image.download_url} alt={image.author} className="imageStyle" />
            </li>
          ))}
        </ul>
      )}

      {loading && <li>Loading ...</li>}

      <div ref={setElement} className="buttonContainer">
        <button className="buttonStyle">Load More</button>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

It’s important to note that to some extent, IO is safe to use and supported across most browsers. However, you can always use a Polyfill if you are not comfortable. You can refer to this to learn more about support:

Browser support

Adios 👋🏾

Discussion (6)

pic
Editor guide
Collapse
tmurvv profile image
Tisha Murvihill

I love this code, but I would like to pass in the data as a prop so that on a rerender it will access a completely new data set via props and then infinitely scroll on the new data set. Currently every time the component rerenders it continues to show whatever data was initially passed in on the first mount.

Ideas?

Collapse
mejanhaque profile image
Muhammad Mejanul Haque

i had a problem with using boundingClientRect instead of isIntersecting on page load. if the element is visible on page load it does not work, but intersectionRation and isIntesecting worked. so moved into isIntesecting. if there is a better method, it would be helpful. Thanks for the post by the way.

Collapse
somtougeh profile image
Somto M.Ugeh Author

Thank you, Muhammad. I would look into this and get back to you.

Collapse
segebee profile image
Segun Abisagbo

beautifully written and very insightful

Collapse
somtougeh profile image
Somto M.Ugeh Author

Thank you Segun

Collapse
hnrq profile image
Henrique Ramos • Edited

Have never heard of Intersection Observer. Neat!