DEV Community

Cover image for React Hooks for infinite scroll: An advanced tutorial
Matt Angelosanto for LogRocket

Posted on • Originally published at blog.logrocket.com

React Hooks for infinite scroll: An advanced tutorial

Written by Luke Denton ✏️

Introduction

Infinite loading is a pattern that is very common in ecommerce applications. Online retailers like this pattern for loading products as it allows a user to seamlessly browse through every product available within a category, without having to pause every so often and wait for the next page to load.

In this article, we’re going to walk through creating a super-powered infinite loading Hook for React that can be used as a guide for you to create your very own!

While the code in this article will be React specifically, the ideas behind the code are easily applicable to any context, including Vue.js, Svelte, vanilla JavaScript, and many others.

Creating a Hook for infinite scroll

Before we get into the details, let’s first outline what the Hook will and will not manage.

Rendering isn’t managed by the Hook; that’s up to the component. API communication also won’t be included, however, the Hook can be extended to include it. In fact, depending on your use case, it will probably be a good idea to package it all!

What will our Hook manage? First and foremost, the items that are visible on the page. Specifically, products, blog posts, list items, links, and anything that is a repeated on a page and loaded from an API call.

We’re also assuming that React Router is prevalent across most, if not all, React applications that include any sort of routing, so we’ll use that dependency.

Lets kick off by managing our items’ state:

import { useState } from 'react';

const useInfiniteLoading = (() => {
  const [items, setItems] = useState([]);

  return {
    items
  };
}
Enter fullscreen mode Exit fullscreen mode

Next, let’s add a function that will be called each time we want to load the next page of items.

As mentioned earlier, API communication isn’t part of this article. The actual API library doesn’t matter, we just need a function that accepts a page number variable, and returns an array of items corresponding to that page number. This can be using GraphQL, Rest, local file lookup, or anything the project needs!

const useInfiniteLoading = (props) => {
  const { getItems } = props; /* 1 */
  const [items, setItems] = useState([]);
  const pageToLoad = useRef(new URLSearchParams(window.location.search).get('page') || 1); /* 2 */
  const initialPageLoaded = useRef(false);
  const [hasMore, setHasMore] = useState(true);

  const loadItems = async () => { /* 3 */
    const data = await getItems({
      page: pageToLoad.current
    });
    setHasMore(data.totalPages > pageToLoad.current); /* 4 */
    setItems(prevItems => [...prevItems, ...data]);
  };

  useEffect(() => {
    if (initialPageLoaded.current) {
      return;
    }

    loadItems(); /* 5 */
    initialPageLoaded.current = true;
  }, [loadItems])

  return {
    items,
    hasMore,
    loadItems
  };
}
Enter fullscreen mode Exit fullscreen mode

Let’s quickly go through this code:

  1. First, we’re accepting one prop to the Hook: getItems. getItems is a function that will accept an object with a page property, the value of which is the “page” of items that we want to load
  2. Next, we grab a page query param that indicates the starting page, defaulting to the first page
  3. loadItems is the function that our component can call when we want to actually load the next page of products. As we go through the article, we’ll explore the different ways to use this function, whether that be automatic, manual, or a mix of the two
  4. The data returned from getItems will also include how many total pages of items there are available. This will be used to conditionally hide the “Load More” button when all items are loaded
  5. This makes sure the page is populated with initial products

That’s it, we now have a Hook that will handle infinitely loading our items!

Here is a quick example of what it looks like to use this Hook:

import { useInfiniteLoading } from './useInfiniteLoading';

export default MyList = () => {
    const { items, hasMore, loadItems } = useInfiniteLoading({
      getItems: ({ page }) => { /* Call API endpoint */ }
    });

    return (
        <div>
            <ul>
                {items.map(item => (
                    <li key={item.id}>
                        {item.name}
                    </li>
                ))}
            </ul>
            {hasMore && 
              <button onClick={() =>loadItems()}>Load More</button>
            }
        </div>
    );
}
Enter fullscreen mode Exit fullscreen mode

It’s straightforward, it’s simple, and it can be better.

Loading data in two directions

What if a user visits a URL with a page number directly? For example, www.myonlinestore.com/jumpers?page=4, how would users get to the content on pages one, two, or three? Do we expect them to edit the URL directly themselves?

We should provide users a way to load a previous page, which can be done simply using a "Load Previous" (or similar) button, placed at the top of the list of items.

Here is what that looks like in code:

import { useEffect, useRef, useState } from 'react';
import { useHistory } from 'react-router-dom';

export const useInfiniteLoading = (props) => {
  const { getItems } = props;
  const [items, setItems] = useState([]);
  const pageToLoad = useRef(new URLSearchParams(window.location.search).get('page') || 1);
  const initialPageLoaded = useRef(false);
  const [hasNext, setHasNext] = useState(true); /* 1 */
  const [hasPrevious, setHasPrevious] = useState(() => pageToLoad.current !== 1); /* 2 */
  const history = useHistory();

  const loadItems = async (page, itemCombineMethod) => {
    const data = await getItems({ page });
    setHasNext(data.totalPages > pageToLoad.current); /* 3 */
    setHasPrevious(pageToLoad.current > 1); /* 4 */
    setItems(prevItems => {
      /* 5 */
      return itemCombineMethod === 'prepend' ?
        [...data.items, ...prevItems] :
        [...prevItems, ...data.items]
    });
  };

  const loadNext = () => {
    pageToLoad.current = Number(pageToLoad.current) + 1;
    history.replace(`?page=${pageToLoad.current}`);
    loadItems(pageToLoad.current, 'append');
  }

  const loadPrevious = () => {
    pageToLoad.current = Number(pageToLoad.current) - 1;
    history.replace(`?page=${pageToLoad.current}`);
    loadItems(pageToLoad.current, 'prepend');
  }

  useEffect(() => {
    if (initialPageLoaded.current) {
      return;
    }

    loadItems(pageToLoad.current, 'append');
    initialPageLoaded.current = true;
  }, [loadItems])

  return {
    items,
    hasNext,
    hasPrevious,
    loadNext,
    loadPrevious
  };
}
Enter fullscreen mode Exit fullscreen mode
  1. Refactor hasMore to hasNext, as it’ll read better alongside the next point
  2. Add hasPrevious, which will essentially keep track of whether we’ve loaded the lowest page yet (the lowest page being page number one)
  3. Assuming that the getItems query will return page information, we’ll use a totalPages value to compare against the page we’ve just loaded in order to determine if we should still show “Load More”
  4. If we’ve loaded page one, then we no longer have to display the “Load Previous” button
  5. While the Hook isn’t responsible for rendering the items, it is responsible for the order in which those items are rendered. This part will make sure that when we’re loading previous items, we place them on the screen before the current items. This makes the key prop absolutely critical for the component that is rendering the items, so be sure to keep that in mind when using this in the wild

This is how it will look when used correctly:

import { useRef } from 'react';
import { useInfiniteLoading } from './useInfiniteLoading';

export default MyList = () => { 
    const { items, hasNext, hasPrevious, loadNext, loadPrevious } = useInfiniteLoading({
      getItems: ({ page }) => { /* Call API endpoint */ }
    });

    return (
        <div>
            {hasPrevious && 
              <button onClick={() => loadPrevious()}>Load Previous</button>
            }
            <ul>
                {items.map(item => (
                    <li key={item.id}>
                        {item.name}
                    </li>
                ))}
            </ul>
            {hasNext && 
              <button onClick={() =>loadNext()}>Load More</button>
            }
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

Some readers might notice a bug that has just been introduced by implementing the “Load Previous” button. For those that haven’t, have another look at the code, and ask yourself what happens if a user clicks on the “Load Previous” button, then clicks on “Load Next.” Which pages would load?

As we’re using a single variable to keep track of the most recently loaded page, the code “forgets_”_ that we’ve already loaded that previous page’s next page. This means if a user starts on page five (through a direct link), then clicks on “Load Previous,” the application will read the pageToLoad ref, see that the user is on page five, send a request to get the items on page four, and then update the ref to indicate the user is looking at page four data.

The user might then decide to scroll down and press the “Load More” button. The application will look at the pageToLoad ref’s value, see the user has just been looking at page four, send a request for page five data, and then update the ref to indicate the user is looking at page five data. After that very simple interaction, the user now has page four’s data, and two sets of page five’s data.

To work around this issue, we again will make use of some refs to track the lowest page loaded, and the highest page loaded. These will be the variables that we use to determine the next page to load:

>const useInfiniteLoading = (props) => {
  // ...
  const initialPage = useRef(new URLSearchParams(window.location.search).get('page') || 1); /* 6 */
  // ...
  const lowestPageLoaded = useRef(initialPage.current); /* 7 */
  const highestPageLoaded = useRef(initialPage.current); /* 7 */

  const loadItems = (page, itemCombineMethod) => { 
    // ...
    setHasNext(data.totalPages > page);
    setHasPrevious(page > 1);
    // ...
  }

  const loadNext = () => {
    const nextPage = highestPageLoaded.current + 1; /* 8 */
    loadItems(nextPage, 'append');
    highestPageLoaded.current = nextPage;
  }

  const loadPrevious = () => {
    const nextPage = lowestPageLoaded.current - 1; /* 8 */
    if (nextPage < 1) return; /* 9 */
    loadItems(pageToLoad.current, 'prepend');
    lowestPageLoaded.current = nextPage;
  }

  return {
    // ...
  };
}
Enter fullscreen mode Exit fullscreen mode

Here’s a closer look at this code:

  1. Refactor pageToLoad to initialPage, as it’s only going to be used for initializing
  2. Set up two new refs to track the pages that are loaded in either direction
  3. Make use of the direction tracking refs to determine the next page to load
  4. Safety check to make sure we’re not trying to load pages lower than page one

There we have it, infinite loading in two directions! Be sure to take extra special note of the code breakdown of the first code block in this section; omitting the key value (or using the array index) will result in rendering bugs that will be very hard to fix.

Perceived performance

Perceived performance is the notion of how fast an application feels. This isn’t something that can really be backed up by analytics or measurements, as it’s just a feeling - you’ve probably experienced it many times before.

For example, if we display a loading indicator for the entire time it takes to download all of the data required for a page, and then display a fully rendered page, that page load isn’t going to feel as fast as a page that progressively loads as data is available (or that uses placeholder content). The user can see things happening, rather than see nothing and then everything.

We can make our infinite loading Hook feel instant be pre-fetching the next page of items even before the user has requested them. This technique will work exceptionally well when we’re using a manually triggered “Load More” button.

For automatically triggered “Load More” buttons, the technique will still work, but there are arguably better ways to go about making it feel like the pages are loading instantly. We’ll discuss the automatically triggered “Load More” button in the next section.

The technique we’re going to use for making our infinite loading Hook appear instant is to always load the page after the next one, then store that page in memory, waiting to be placed directly into state and rendered onto the page.

This might be best explained by a sequence diagram:

Instant infinite loading hook sequence diagram

The idea is that the next page of items is already waiting in memory for us, so when the user clicks on “Load More," we can immediately put those items into state, and have the page re-render with the new items. After the page has rendered, we request the following pages’ data.

Clicking “Load More” does actually trigger a network request, but it’s a network request for the page after the next page.

This technique raises a couple of questions: if we’re downloading the data anyway, why not just render that for the user to see? Isn’t it wasted bandwidth?

The reason for not simply rendering all of the products anyway is because we don’t want the user to become overwhelmed. Allowing the user to trigger when the next page of products displays gives them a feeling of control, and they can take in the products at their own pace. Also, if we’re talking about a manually triggered “Load More” button, they will be able to get to the footer quickly, rather than having to scroll past many pages worth of products.

Is downloading a set of items that a user might not see wasted bandwidth? Yes. But it’s a small price to pay for an application that feels like lightning, and that users will find a joy to use.

We can certainly be mindful of users who might have limited bandwidth though, making use of an experimental API that is currently available in Chrome, Edge, and Opera, as well as all mobile browsers (except Safari): NetworkInformation.

Specifically, we can use a mix of the effectiveType and saveData properties of NetworkInformation to determine if a user has a capable connection that the download of the next page will be quick enough so as to not block any user triggered API calls, and also to determine if a user has indicated they want reduced data usage. More information about this API can be found on MDN.

Automatic infinite loading

The most performant way to implement anything based on scroll is to make use of the Intersection Observer API.

Even though we’re in React where we don’t directly interact with the HTML elements that are rendering, it’s still relatively straightforward to set this up. Using a ref, attached to a “Load More” button, we can detect when that “Load More” button is in the viewport (or about to be in the viewport) then automatically trigger the action on that button, loading and rendering the next page of items.

As the purpose of this article is infinite loading, we’re not going to go into the implementation details of the Intersection Observer API, and instead use an existing React Hook that provides that functionality for us, react-cool-inview.

The implementation using react-cool-inview couldn’t be simpler:

import useInView from 'react-cool-inview';

const useInfiniteLoading = (props) => {
  // ...

  const { observe } = useInView({
    onEnter: () => {
      loadNext();
    },
  });

  return {
    // ...
    loadMoreRef: observe
  };
}
Enter fullscreen mode Exit fullscreen mode

In this block, we are making use of the loadMoreRef on our “Load More” button:

import { useRef } from 'react';
import { useInfiniteLoading } from './useInfiniteLoading';

export default MyList = () => { 
    const { loadMoreRef /* ... */ } = useInfiniteLoading({
      getItems: ({ page }) => { /* Call API endpoint */ }
    });

    return (
        <div>
            {/* ... */}

            {hasNext && 
              <button ref={loadMoreRef} onClick={() =>loadNext()}>Load More</button>
            }
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

As mentioned earlier, we can make the automatic infinite loading pattern feel faster by playing with the options provided to the Intersection Observer Hook. For example, instead of waiting for the “Load More” button to be within the viewport, wait until it’s just about to be in the viewport, or wait until there is a single row of items out of view, allowing the next set of items to load and therefore prevent the user from ever actually seeing the “Load More” button.

These are considerations that I encourage you to play around with in your implementation of an infinite loading Hook.

Preventing infinite loading triggering on page load

There is a common issue that occurs while using the Intersection Observer API to automatically trigger a page load when an item is in the viewport. While data is loading, there is nothing to render on the page, so the “Load More” button that is supposed to be below all of the items and outside of the viewport, will in fact be inside the viewport until that first page of data has loaded and pushes the button down.

The way to fix this is to force the height of the items on the page while it’s in a loading state; I suggest using a skeleton loader. Setting a minimum height on the page container would also work, but does introduce issues of its own.

Finally, we have the “loading data both ways” consideration. That is, do we automatically load the previous page of items using Intersection Observer API? We certainly could, but I wouldn’t recommend it - the “Load Previous” button will start in the viewport, meaning the previous page’s items will automatically load, causing the user to lose their place as the browser attempts to restore scroll position.

Infinite loading options

Let’s start expanding our infinite loading Hook with some options. We will have three options for the Hook: manual loading, partial infinite loading, and infinite infinite loading.

Manual loading

This is the option that we have briefly discussed earlier; the next page of items will only load when the user clicks on a “Load More” button. Implementation of this is really easy, simply making use of a callback function that is triggered when a user activates a button.

Infinite infinite loading

This is a fun one to say, and represents the “Load More” button being automatically triggered by the application as the user scrolls down.

We discussed its implementation in the previous section. The main outcome of this option is that pages of data will continue to load for as long as the user is scrolling, and as long as there are more items to load.

Partial infinite loading

Finally, we have a pattern that is a mix of manual and infinite infinite loading. This pattern will use a ref to keep track of how many times an automatic page load has been triggered and, once this value equals a predefined maximum, will stop automatically loading pages and instead fall back to a user having to manually press the “Load More” button.

Here’s an example of how we would set that up in our Hook:

import { useEffect, useRef } from 'react';

export const useInfiniteLoading = (props) => {
  const { loadingType, partialInfiniteLimit = -1 /* ... */ } = props; /* 1 */
  const remainingPagesToAutoload = useRef(loadingType === 'manual' ? 0 : partialInfiniteLimit);
  const loadMoreRef = useRef(null);

  const loadNext = () => {/* ... */}

  const { observe, unobserve } = useInView({
    onEnter: () => {
      if (remainingPagesToAutoload.current === 0) { /* 2 */
        unobserve();
        return;
      }

      remainingPagesToAutoload.current = remainingPagesToAutoload.current - 1;
      loadNext();
    },
  });

  // ...

  return {
    loadMoreRef,
    handleLoadMore
    /* ... */
  };
}
Enter fullscreen mode Exit fullscreen mode
  1. Here, we accept two new props:
    • The first is loadingType, which will be a one of three string values: “manual”, “partial”, and “infinite”
    • The second is partialInfiniteLimit, which will indicate how many times the “load more” function should automatically trigger when loadingType is “partial”
  2. Hooks can’t be conditional, so we just turn off the Intersection Observer Hook the first time it’s called for instances where the loadingType is “manual” or when the Hook has hit the automatic loading limit

We can take this approach even further by giving a user a second button: Load More and Continue Auto Load More. This example of a second button is a little wordy, and the implementation is completely up to the context of the application, but essentially it means putting the power into the users’ hands. If the user wants the pages of data to continue to automatically load, they can communicate this preference to the app .

Final thoughts

There we have it, we have now covered the process of creating an infinite loading Hook, with some special extra features.

I highly encourage you to play around with the code provided in this repo and use it as a starting point for your own infinite loading Hook. It provides all of the code we’ve talked about in this article: a fully featured useInfiniteLoading Hook, including all extra features like manual infinite loading, partial infinite loading, infinite infinite loading, and conditional pre-fetching.

It doesn’t have to be plug-and-play into every possible project, sometimes just making it work really well for a single project is all we need!


Full visibility into production React apps

Debugging React applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.

LogRocket Dashboard Free Trial Banner

LogRocket is like a DVR for web apps, recording literally everything that happens on your React app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.

The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.

Modernize how you debug your React apps — start monitoring for free.

Discussion (0)