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.
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 }; }
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 }; }
Let’s quickly go through this code:
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 loadpage
query param that indicates the starting page, defaulting to the first pageloadItems
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 twogetItems
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 loadedThat’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> ); }
It’s straightforward, it’s simple, and it can be better.
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 }; }
hasMore
to hasNext
, as it’ll read better alongside the next pointhasPrevious
, which will essentially keep track of whether we’ve loaded the lowest page yet (the lowest page being page number one)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”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 wildThis 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> ) }
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 { // ... }; }
Here’s a closer look at this code:
pageToLoad
to initialPage
, as it’s only going to be used for initializingThere 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 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:
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.
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 }; }
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> ) }
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.
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.
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.
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.
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.
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 /* ... */ }; }
loadingType
, which will be a one of three string values: “manual”, “partial”, and “infinite”partialInfiniteLimit
, which will indicate how many times the “load more” function should automatically trigger when loadingType
is “partial”loadingType
is “manual” or when the Hook has hit the automatic loading limitWe 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 .
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!
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]
3 Replies to "React Hooks for infinite scroll: An advanced tutorial"
This useEffect makes no sense, since you have a new loadItems instance in dependency array every render, the effect will execute all renders. You would get the same result without it, or improving the loadItems method
Hey Rhoger! Thanks for pointing that out, you’re absolutely correct. While the loadItems function itself isn’t going to run on every render, as it’s “protected” by the ref, the useEffect hook will, which could definitely be optimised a bit better. This is what happens when trying to anticipate what the exhaustive deps eslint rule would indicate, without actually using a linter when writing the code 😬.
To fix, we could either remove loadItems from the dependency array of the useEffect hook, and then add an eslint-disable-line comment, or we could wrap the loadItems function in a useCallback hook.
Thanks again!
Thanks Luke for such a helpful tutorial!