DEV Community

Cover image for Infinite scrolling in React with intersection observer
Yogini Bende
Yogini Bende

Posted on • Updated on

Infinite scrolling in React with intersection observer

Hello folks,

Few days back I came across the use-case of infinite scroll in React. For this, I used Intersection Observer and found different ways of implementing it in infinite scrolling.

Before we dive in, let's first understand our problem statement better. Consider an API which gives you list of users and some of their basic details. The task here is to show list of all users in cards. Simple right?

Now, consider there are thousands of users and the API we are using is paginated. In this case, there will be these two ways to use our paginated API -

  1. Use next/prev buttons to go through different pages
  2. Use infinite scroll

As the article title says, we are going with 2nd approach.😅
Now, let's see how?

  1. We will be calling our API to get first 25 results.
  2. Once the user scrolls through the list and reach to the last element, we will make another API call and pull next set of users in the view.

This way, even if user keep scrolling, they will always see list of users until they reach till the end.

Before moving to the implementation part, let me give you the brief idea of Intersection Observer

What is Intersection Observer?

The Intersection Observer is a browser API that provides a way to asynchronously observe or detect visibility of two elements in relation to each other.

As per MDN, this API is mostly used for performing visibility related tasks which includes lazy-loading of images and implementing "infinite scrolling" web sites, where more and more content is loaded and rendered as you scroll.

You can check detailed information of Intersection Observer here.

Implementing Infinite Scroll

For the infinite scrolling we will be using an open source RandomUserAPI.

For basic project setup, I created a simple React project with create-react-app and added Tailwind CSS to it. Also, for calling APIs, I added axios to the same project.

I have divided the implementation in 2 steps as follows -

1. Calling API, storing and displaying data.

With our basic setup in place, let's see the first version of code where we are calling a user API to get the list of users.

// app.js
import axios from 'axios';
import { useEffect, useState } from 'react';

const TOTAL_PAGES = 3;

const App = () => {
    const [loading, setLoading] = useState(true);
    const [allUsers, setAllUsers] = useState([]);
    const [pageNum, setPageNum] = useState(1);

    const callUser = async () => {
        setLoading(true);
        let response = await axios.get(
            `https://randomuser.me/api/?page=${pageNum}&results=25&seed=abc`
        );
        setAllUsers(response.data.results);
        setLoading(false);
    };

    useEffect(() => {
        if (pageNum <= TOTAL_PAGES) {
            callUser();
        }
    }, [pageNum]);

    const UserCard = ({ data }) => {
        return (
            <div className='p-4 border border-gray-500 rounded bg-white flex items-center'>
                <div>
                    <img
                        src={data.picture.medium}
                        className='w-16 h-16 rounded-full border-2 border-green-600'
                        alt='user'
                    />
                </div>

                <div className='ml-3'>
                    <p className='text-base font-bold'>
                        {data.name.first} {data.name.last}
                    </p>
                    <p className='text-sm text-gray-800'>
                        {data.location.city}, {data.location.country}
                    </p>
                    <p className='text-sm text-gray-500 break-all'>
                        {data.email}
                    </p>
                </div>
            </div>
        );
    };

    return (
        <div className='mx-44 bg-gray-100 p-6'>
            <h1 className='text-3xl text-center mt-4 mb-10'>All users</h1>

            <div className='grid grid-cols-3 gap-4'>
                {allUsers.length > 0 &&
                    allUsers.map((user, i) => {
                        return (
                            <div key={`${user.name.first}-${i}`}>
                                <UserCard data={user} />
                            </div>
                        );
                    })}
            </div>
            {loading && <p className='text-center'>loading...</p>}
        </div>
    );
};

export default App;

Enter fullscreen mode Exit fullscreen mode

This is how our page will look like 👇
This is how our page will look like

The code is pretty straightforward. In the callUser function, we are calling the API and storing the result in allUsers state. Below, we are showing each user from the allUsers array using a card component UserCard.

You will see one const defined on top of the component TOTAL_PAGES, this is to restrict total number of pages we want to traverse throughout application. In real-world applications, this won't be needed as the API will give you the details of total pages available.

Also, you might have notice, we have defined a state to store page number but till now, haven't used it correctly. This is because we want to change this page number from our intersection observer.

2. Adding Intersection Observer and incrementing page number

To do an infinite scroll, we need to increment page number count when last element of the list is visible to user. This will be done by intersection observer.

Our intersection observer will observe if the last element is visible or not, if it is, we will increment the page number by 1. As our useEffect will run on change in page number, the API will get called and hence we will get list of more users.

After understanding this logic, let's see the working code -

// App.js

const App = () => {
    const [loading, setLoading] = useState(true);
    const [allUsers, setAllUsers] = useState([]);
    const [pageNum, setPageNum] = useState(1);
    const [lastElement, setLastElement] = useState(null);

    const observer = useRef(
        new IntersectionObserver(
            (entries) => {
                const first = entries[0];
                if (first.isIntersecting) {
                    setPageNum((no) => no + 1);
                }
            })
    );

    const callUser = async () => {
        setLoading(true);
        let response = await axios.get(
            `https://randomuser.me/api/?page=${pageNum}&results=25&seed=abc`
        );
        let all = new Set([...allUsers, ...response.data.results]);
        setAllUsers([...all]);
        setLoading(false);
    };

    useEffect(() => {
        if (pageNum <= TOTAL_PAGES) {
            callUser();
        }
    }, [pageNum]);

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

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

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

    const UserCard = ({ data }) => {
        return (
            <div className='p-4 border border-gray-500 rounded bg-white flex items-center'>
                <div>
                    <img
                        src={data.picture.medium}
                        className='w-16 h-16 rounded-full border-2 border-green-600'
                        alt='user'
                    />
                </div>

                <div className='ml-3'>
                    <p className='text-base font-bold'>
                        {data.name.first} {data.name.last}
                    </p>
                    <p className='text-sm text-gray-800'>
                        {data.location.city}, {data.location.country}
                    </p>
                    <p className='text-sm text-gray-500 break-all'>
                        {data.email}
                    </p>
                </div>
            </div>
        );
    };

    return (
        <div className='mx-44 bg-gray-100 p-6'>
            <h1 className='text-3xl text-center mt-4 mb-10'>All users</h1>

            <div className='grid grid-cols-3 gap-4'>
                {allUsers.length > 0 &&
                    allUsers.map((user, i) => {
                        return i === allUsers.length - 1 &&
                            !loading &&
                            pageNum <= TOTAL_PAGES ? (
                            <div
                                key={`${user.name.first}-${i}`}
                                ref={setLastElement}
                            >
                                <UserCard data={user} />
                            </div>
                        ) : (
                            <UserCard
                                data={user}
                                key={`${user.name.first}-${i}`}
                            />
                        );
                    })}
            </div>
            {loading && <p className='text-center'>loading...</p>}

            {pageNum - 1 === TOTAL_PAGES && (
                <p className='text-center my-10'>♥</p>
            )}
        </div>
    );
};
Enter fullscreen mode Exit fullscreen mode

Let's understand the code in-depth.

We have defined the Intersection Observer and stored it to const observer. The intersection observer have a callback function which accept array of all the intersecting objects. But since, we will be passing only last element to it, we are always checking the 0th entry of this array. If that element intersects means become visible, we will increment the page number.

We have added one more state lastElement and initialised it to null. Inside the page, we will be passing last element of the array to this state.

Hence, when the value of lastElement state will be changed calling another useEffect (with lastElement in dependency-array). In this useEffect, if we get value of lastElement we will pass that element to our intersection observer to observe. Our observer will then check the intersection of this element and increment the page count once this happens.

As the page number changes, the API will be called and more users will be fetched. Notice the small change we did to add these new users to existing state and avoiding duplications.

And The app will run effortlessly and you can now see infinite scroll in action!🥁

That is it for now! If you want to see the full code for this, you can check that in my Github repository here.

Thank you so much for reading this article. Let me know your thoughts on this and you can also connect with me on Twitter or buy me a coffee if you like my articles.

*Happy coding and keep learning 🙌 *

Top comments (13)

Collapse
 
udittakkar profile image
Udit Takkar

Why have to used this ref={setLastElement} ? isn't it supposed to be ref={observer} ?

Collapse
 
rakshit profile image
Rakshit

Read about callback refs for this.

Collapse
 
jai_type profile image
Jai Sandhu

This is great, do you have a solution for infinite scroll + virtualisation? That's the holy grail I haven't been able to implement well

Collapse
 
galelmalah profile image
Gal Elmalah • Edited

A lot of React libs doing that out there Jai Sandhu

Collapse
 
jai_type profile image
Jai Sandhu

Haven't found a good one that caters for different height cells and infinite scrolling

Thread Thread
 
globalroo profile image
Andy

Hi Jai. This solution has an interesting take on different height cells in a virtualised list. dev.to/miketalbot/react-virtual-wi....

Thread Thread
 
jai_type profile image
Jai Sandhu

Oooh wow thanks that looks awesome!

Collapse
 
akshay1502 profile image
Akshay Shinde

Your arcticle helped a lot. Thanks for indetailed explanation. I have some suggestion.
There are 2 ways:

  1. either add observer for the last element
  2. add a empty div below your last ele, and set observer on it.

with 1st we need calculate the last ele, with 2nd it becomes much cleaner and simpler.
Adding YT link for those who want video explanation.
https://www.youtube.com/watch?v=8kLOvs1prEM&t=3s

Collapse
 
nguyencqchi profile image
Chi Nguyen

This is great. Do you have any solution for combining infinite scroll and scroll into view? I'm trying to implement into my project, which has a navigation that I can click to go to a certain section and update the active section on my navigation, and also update my navigation whenever I scroll to a section

Collapse
 
fires3as0n profile image
fires3as0n

Why do you use

const observer = useRef(...);

to store a reference to an observer?

useRef(value)

is nothing but

useMemo(() =>({current: value}), []);

It is a convenience hook often used to store auto-updated refs to an elements (with its return value passed as a ref prop), or in rare cases when we need to update persistent state without causing component to re-render (by manually updating .current value)

Here you are just storing the reference, so it is more intuitive and less confusing to use

const observer = useMemo(() => new Observer(...), [])

instead.

Also since you are still using ref but update it manually using "callback ref", the presence of useRef() in the code makes it harder to understand especially to a novice.

Collapse
 
amantiwari1 profile image
Aman Tiwari

use this library to save time :)
react-query.tanstack.com/reference...

Collapse
 
willaiem profile image
Damian Żygadło

for this hint you can make me louda

Collapse
 
mathrubootamfan profile image
C

the page Number is incrementing even when the already intersected element comes down. how can we resolve this issue?