Today we’re going to dive deeper into React development and learn how to build an infinite scroll component in React using React Hooks. If you are just starting out with React hooks make sure to check out this guide. If not, Let’s get started!

What We’re Building

A webpage showing a list of items that when scrolled to the bottom of the page, the list loads more items infinitely.
What we’re building — an infinite scroll component that uses React Hooks!

You may not have heard of an infinite scroller before, but if you’ve ever used Facebook, Instagram or Reddit, you’ve definitely used one.

An infinite scroller is a modern alternative to pagination.

Rather than wait for the user to click next page, new content is automatically loaded every time the user reaches the bottom of the page. Therefore, there’s a never-ending supply of content for the user to read through.

Setting Up our Infinite Scroll Component

If you have an existing React project that you want to add the infinite scroller functionality to, you can skip this section.

For everyone else, the easiest way to get started is to use Create React App to create a brand new React project. Check out this guide to creating your first React app.

Once you have the app up and running, strip out all of the unneccesary code from the App.js component, so it looks like this:

I’m using Bootstrap in this tutorial. If you want to add bootstrap to your new React project, run the following command in your terminal at your root project directory: npm install –save bootstrap.

import React from 'react';

const App = () => (
  <div className="container">
    <div className="row">
      <div className="col-6 justify-content-center my-5">
        ...
      </div>
    </div>
  </div>
);

export default App;

Building the List Component

Infinite scroll works great for loading lists of things like photos, status updates, and table data.

In this tutorial, we’ll stick to showing a simple list of numbers, but by the end of this tutorial, you’ll be able to use what you’ve built for any type of list.

Create a new file called List.js inside of the src directory, and fill it with the following code:

import React, { useState } from 'react';

const List = () => {
  const [listItems, setListItems] = useState(Array.from(Array(30).keys(), n => n + 1));
  
  return (
    <>
      <ul className="list-group mb-2">
        {listItems.map(listItem => <li className="list-group-item">List Item {listItem}</li>)}
      </ul>
    </>
  );
};

export default List;

We do a few things using the code above:

  • Importing React and the new useState hook.
  • Defining a new stateful functional component called List.
  • Instantiating a new variable called listItems, and a new setter function called setListItems with the help of our new useState hook.
  • Setting the initial value of listItems to an Array filled with 30 values from 1 to 30.
  • Finally, returning some HTML which displays an unordered list, loops through listItems and displayed a <li> tag with the value inside for every item in the listItems array.

Phew, we’re doing a lot for such a small amount of code! If you haven’t used React Hooks before, check out our Simple Introduction to React Hooks.

We need to use our new List component Before we see it in action.

Jump back to App.js and import the List component at the top of the file. Then, declare it in the App.js return function.

import React from 'react';
import List from './List';

const App = () => (
  <div className="container">
    <div className="row">
      <div className="col-6 justify-content-center my-5">
        <List />
      </div>
    </div>
  </div>
);

export default App;

Save your files, jump over to your app running in the browser and you should see a brand new list showing 30 items.

Our List component, that doesn’t really do any infinite scrolling…yet.

Detecting the Bottom of the Page

Great! We’ve got a React List component, but it doesn’t infinite scroll. So let’s change that next.

An infinite scroll is triggered when you scroll to the bottom of the page. Therefore, we need to detect that the webpage scrollbar is at the end of the page.

There are two parts to accomplishing that. First, we add an on scroll event listener to the Window object to call a function called handleScroll every time the window scrolls.

Add the following to the List.js component:

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

const List = () => {
  ...
  useEffect(() => {
    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);
  ...
};

export default List;

In the code above, we import another Hook, the useEffect hook. This gives us functionality similar to the componentDidMount and componentWillUpdate lifecycle methods in React class components.

Let’s look at useEffect a little closer.

List.js
useEffect(() => { window.addEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll); }, []);

Notice how we use an empty array [] as the second parameter of useEffect?

That tells the useEffect function to act like componentDidMount and only run one time, when the component first mounts.

The second part to detecting the bottom of the page is to check if the Window object’s inner height, plus the Document object’s scrollTop, is equal to the Document’s offsetHeight.

If it is, then we want to fetch more items to add to our List.

List.js
... function handleScroll() { if (window.innerHeight + document.documentElement.scrollTop !== document.documentElement.offsetHeight) return; console.log('Fetch more list items!'); } ...

Let’s tie all those two steps together, so our List component will look like this:

List.js
import React, { useState, useEffect } from 'react'; const List = () => { const [listItems, setListItems] = useState(Array.from(Array(30).keys(), n => n + 1)); useEffect(() => { window.addEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll); }, []); function handleScroll() { if (window.innerHeight + document.documentElement.scrollTop !== document.documentElement.offsetHeight) return; console.log('Fetch more list items!'); } return ( <> <ul className="list-group mb-2"> {listItems.map(listItem => <li className="list-group-item">List Item {listItem}</li>)} </ul> </> ); }; export default List;

Handling the isFetching State

Now that we have the scroll detection working, the next step is to fetch more list items and append them to the end of the listItems array.

Let’s stop for a moment and think about our List component in terms of its state.

When we scroll to the bottom of the page, List has to know to fetch more list items. Therefore its state changes from not fetching to fetching.

As a result, we need to add another state variable to our List component to keep track of that state change. Let’s call it isFetching.

List.js
... const List = () => { const [listItems, setListItems] = useState(Array.from(Array(30).keys(), n => n + 1)); const [isFetching, setIsFetching] = useState(false); ... };

We define a new state variable called isFetching, along with a state setter function called setIsFetching. Let’s set the initial value to false because when our web app loads for the first time, the window is scrolled to the top of the page.

Now that we have this new state variable, let’s modify our handleScroll function so that it sets the state of isFetching to true whenever we scroll to the bottom of the page:

List.js
function handleScroll() { if (window.innerHeight + document.documentElement.scrollTop !== document.documentElement.offsetHeight) return; setIsFetching(true); }

Fetching More List Items

Right now we’re not doing anything with our new isFetching state change. Let’s change that.

With React Hooks and the useEffect Hook, we can now listen out for changes in state. Therefore, why don’t we listen for when isFetching changes to true and call a function to get more list items?

Add another useEffect function that listens for a change to isFetching. We do that by putting isFetching inside the array that’s passed to the second parameter to useEffect.

List.js
useEffect(() => { if (!isFetching) return; fetchMoreListItems(); }, [isFetching]);

This effect will call every time isFetching changes state. That includes when it’s false. We don’t want that, so we add a conditional at the top which returns whenever isFetching is false.

Finally, let’s add the function to fetch more list items.

List.js
... function fetchMoreListItems() { setTimeout(() => { setListItems(prevState => ([...prevState, ...Array.from(Array(20).keys(), n => n + prevState.length + 1)])); setIsFetching(false); }, 2000); } ...

For demo purposes, I’ve wrapped the setter inside a timeout to simulate two seconds of loading time that you might experience from a real-world application.

Once two seconds elapses, we call setListItems, and add another 20 listItems to the array. Afterwards, we set isFetching to false.

Last but not least, let’s add a label underneath the list to tell the user that we’re fetching more items when isFetching is true.

List.js
... return ( <> <ul className="list-group mb-2"> {listItems.map(listItem => <li className="list-group-item">List Item {listItem}</li>)} </ul> {isFetching && 'Fetching more list items...'} </> ); ...

Save your component, jump back to the app running in your browser and take it for a spin!

Our finished React infinite scroll component

You can either stop here or continue reading to see how we can simplify our React infinite scroll component by writing a custom React Hook to handle the logic for us.

Simplifying Infinite Scroll with a Custom React Hook

Although our Infinite Scroll component is working great, it’s not the best implementation.

Imagine you’re building a real-world web app with multiple components that use infinite scroll. For example, a list of users, a list of user’s photos, and a list of status updates…

We’d have to include the infinite scroll logic inside each of those components. That’s bad, because:

  • We’re not staying DRY (Don’t Repeat Yourself)
  • We’re wasting time re-writing code that’s already been written
  • We’re not making it easy on ourselves if we want to change the infinite scroll logic. We’d have to change it in multiple places.

But, what if we didn’t have to duplicate that code?

React Hooks are a new addition since React 16.8. Writing custom React Hooks allow us to write functions to share common stateful logic between multiple components.

In other words, we can pull out all of the infinite scroll logic and put it inside a custom React Hook called useInfiniteScroll.

The useInfiniteScroll React Hook

Create a new file called useInfiniteScroll.js. We’re going to define a new function called useInfiniteScroll inside of it, like so:

useInfiniteScroll.js
import { useState } from 'react'; const useInfiniteScroll = (callback) => { const [isFetching, setIsFetching] = useState(false); }; export default useInfiniteScroll;

Our custom React Hook takes one parameter, a function called callback.

We want our Hook to call whatever function we pass into the callback parameter after it has detected that the scrollbar is at the bottom of the page.

Let’s remove all of the logic we wrote previously in List.js, and put it inside the new useInfiniteScroll Hook:

useInfiniteScroll.js
import { useState, useEffect } from 'react'; const useInfiniteScroll = (callback) => { const [isFetching, setIsFetching] = useState(false); useEffect(() => { window.addEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll); }, []); useEffect(() => { if (!isFetching) return; callback(() => { console.log('called back'); }); }, [isFetching]); function handleScroll() { if (window.innerHeight + document.documentElement.scrollTop !== document.documentElement.offsetHeight || isFetching) return; setIsFetching(true); } return [isFetching, setIsFetching]; }; export default useInfiniteScroll;

Using a custom React Hook has cut down the code in our List component by nearly half.

Our very skinny List component now looks like this:

List.js
import React, { useState } from 'react'; import useInfiniteScroll from "./useInfiniteScroll"; const List = () => { const [listItems, setListItems] = useState(Array.from(Array(30).keys(), n => n + 1)); const [isFetching, setIsFetching] = useInfiniteScroll(fetchMoreListItems); function fetchMoreListItems() { setTimeout(() => { setListItems(prevState => ([...prevState, ...Array.from(Array(20).keys(), n => n + prevState.length + 1)])); setIsFetching(false); }, 2000); } return ( <> <ul className="list-group mb-2"> {listItems.map(listItem => <li className="list-group-item">List Item {listItem}</li>)} </ul> {isFetching && 'Fetching more list items...'} </> ); }; export default List;

Notice that we’re importing useInfiniteScroll at the top of the file, and declaring it under the listItems and setListItems state.

Using our infinite scroll hook custom React Hook is one line. All we have to do is pass it the function that we want it to run. In our case, it’s the fetchMoreListItems function.

useInfiniteScroll.js
const [isFetching, setIsFetching] = useInfiniteScroll(fetchMoreListItems);

Wrapping Up

Well, there you have it. An infinite scroll list component built entirely using React Hooks.

We even got an added bonus of writing a custom React Hook to make our component nice and clean.

The entire codebase is over on our GitHub repository for this tutorial.

Avatar photo
👋 Hey, I'm James Dietrich
James Dietrich is an experienced web developer, educator, and founder of Upmostly.com, a platform offering JavaScript-focused web development tutorials. He's passionate about teaching and inspiring developers, with tutorials covering both frontend and backend development. In his free time, James contributes to the open-source community and champions collaboration for the growth of the web development ecosystem.

💬 Leave a comment

Your email address will not be published. Required fields are marked *

We will never share your email with anyone else.

Comments

I’d avoid scroll events as much as possible, especially if they’re not debounced.

Use react-intersection-observer (https://www.npmjs.com/package/react-intersection-observer) which is a neat abstraction of the Intersection Observer API, and add the W3C’s polyfill so it falls back to scroll events *only* if necessary (https://github.com/w3c/IntersectionObserver/tree/master/polyfill).

Even better, use react-window instead of reinventing the wheel: https://github.com/bvaughn/react-window

And if you *MUST* listen to scroll events, please debounce them. https://css-tricks.com/the-difference-between-throttling-and-debouncing/

I like how simple this solution is. But like another commenter said, one should debounce the scroll event:

. . .

useEffect(() => {
window.addEventListener(‘scroll’, debounce(handleScroll, 500));
return () => window.removeEventListener(‘scroll’, debounce(handleScroll, 500));
}, []);

. . .

const debounce = (func, delay) => {
let inDebounce;
return function() {
clearTimeout(inDebounce);
inDebounce = setTimeout(() => {
func.apply(this, arguments);
}, delay);
}
}

If you replace `document.documentElement.scrollTop` with `Math.max(
window.pageYOffset,
document.documentElement.scrollTop,
document.body.scrollTop` in `handleScroll()` then this will work in Safari. Safari reports 0 for `document.documentElement.scrollTop` and that breaks this check.

This works for me with Intersection-Observer:
“`
function handleScroll() {
const observer = new IntersectionObserver(updateList, {
root: document.body,
rootMargin: “150px”,
threshold: 1,
});

const target = document.querySelector(“#newsList”);
observer.observe(target);

function updateList(entries, observer) {
setIsFetching(true);
}
}
“`