DEV Community

loading...
Cover image for Infinite scrolling in React with intersection observer

Infinite scrolling in React with intersection observer

Yogini Bende
Front End Developer| Javascript Enthusiast | ReactJS | ReactNative | TailwindCSS | CSS | Exploring AWS | Learning Node! | Follow on Twitter for daily updates
・5 min read

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 follow me on twitter for more daily updates 😇

*Happy coding and keep learning 🙌 *

Discussion (6)

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
udittakkar profile image
Udit Takkar

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