How to Create an Infinite Scroll Component in React
Infinite scroll is a popular pagination technique that works by automatically loading new pages with scrolling. No interaction is needed, and content is automatically populated once the user reaches the end of the page.
In this tutorial, we are going to take a look at how to build an infinite scroll component in React using the IntersectionObserver
API. You will also learn about other concepts, such as render props, and using refs.
Creating an InfiniteScroll Component in React
First things first, let's outline how we want our component to look. We want to make things configurable so that it can be reused elsewhere within the same application. Based on this, our component will look like the following at the end of this tutorial:
import InfiniteScroll from './InfiniteScroll'
function App() {
return (
<InfiniteScroll
url="https://dummyjson.com/posts"
limit={50}
render={posts => posts.map((item, index) => (
<article key={index}>
#{item.id}: {item.title}
</article>
))}
>
<div className="loader">Loading...</div>
</InfiniteScroll>
)
}
We want our component to accept three different props that can work together. These are:
url
: The endpoint we want to use for fetching data. In this tutorial, we are using dummyjson.com, a service for generating fake JSON data for development and testing.limit
: The amount of items we want to display for each page.render
: This prop will accept a callback function that has access to the data that is loaded through the provided URL. This is what will be rendered for each item.
The above technique is called render props. It's a common way to dynamically handle render logic instead of implementing the InfiniteScroll
component own render. Whatever is passed to the render prop will be rendered for each item.
We also have the option to add children to our component. In case the resource is loading, this is what we are going to display in place of the render
prop. Now let's take a look at how our component is structured.
Loading the First Page with the Fetch API
As the very first thing, we want to define some state for our component. We are going to make use of the following useState
variables in order to grab the first page:
import React, { useEffect, useState } from 'react'
const InfiniteScroll = ({
url,
limit,
render,
children
}) => {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [page, setPage] = useState(1)
const [endpoint, setEndpoint] = useState(`${url}?limit=${limit || 50}`)
return (...)
}
export default InfiniteScroll
data
: The actual data that we will receive from the passed URL.loading
: A state to tell whether the data is already returned or not. Since we want to grab the first page as soon as the component loads, this will be set totrue
initially.page
: The current page that we are on, which will be1
by default.endpoint
: The final URL we are going to use to make a request. We can make thelimit
prop optional by falling back to 50 as a hardcoded value using a logical OR.
DummyJSON uses the limit
and skip
query params for pagination. You will need to update the URL with your own params to use it with your own API.
Based on the hooks, we can use the data
variable with the render
prop to render the content passed to the render
prop:
return (
<React.Fragment>
{data && render(data)}
{children}
</React.Fragment>
)
However, since our data is currently null
, this will only render the children
, which shows a loading text. To grab the first batch of content, we want to use fetch
on the url
prop. To do this, we can extend our component with the following functions:
...
const [endpoint, setEndpoint] = useState(`${url}?limit=${limit || 50}`)
// A reusable function for generating the final URL for subsequent pages
const getUrl = (url, page, limit) => {
return `${url}?limit=${limit || 50}&skip=${page * limit || 50}`
}
const setInitialData = async () => {
const response = await fetch(endpoint)
const json = await response.json()
setData(json[Object.keys(json)[0]])
setPage(page + 1)
setEndpoint(getUrl(url, page, limit))
setLoading(false)
}
useEffect(() => {
if (!data) {
setInitialData()
}
}, [loading, page, endpoint])
return (...)
Alternatively, we could also use a custom useFetch hook in place of fetch
. Based on the state of our url
, page
and limit
variables, we can create a function called getUrl
for generating the correct URL for each page. We can use this function inside the setInitialData
function which is responsible for grabbing the first page. We can call this function inside a useEffect
if our data
is still null
.
Notice that useEffect
has a dependency on loading
, page
and endpoint
.
Understanding the setInitialData function
To better understand how this piece of code works, let's break down the setInitialData
function with some comments. In order, we want to do the following:
// Set the function `async` to use `await`
const setInitialData = async () => {
// Request the data from the endpoint, and return it in a JSON format
const response = await fetch(endpoint)
const json = await response.json()
// Set the data to the first property of the `json` variable
setData(json[Object.keys(json)[0]])
// Increase our page number by 1
setPage(page + 1)
// Update the url to point to the next page
setEndpoint(getUrl(url, page, limit))
// Tell the component we loaded everything
setLoading(false)
}
There are two main things that we need to point out. Since the actual data of the response (the posts that we will display) will always be the first property of the JSON object, we can grab it by using Object.keys
:
const json = {
posts: [1, 2, 3],
limit: 50
}
Object.keys(json)
<- ['posts', 'limit']
Object.keys(json)[0]
<- 'posts'
json[Object.keys(json)[0]] // Also equivalent to `json.posts`
<- [1, 2, 3]
Next, the getUrl
function will also add a skip
query parameter to the end of the URL. We can multiply the current page with the limit
to get how many results should be skipped. This means we will end up with the following URLs:
https://dummyjson.com/posts?limit=50 # Initial page
https://dummyjson.com/posts?limit=50&skip=50 # 2nd page
https://dummyjson.com/posts?limit=50&skip=100 # 3rd page
Loading Subsequent Pages with Intersection Observer
The only thing left to do is to add the intersection observer to load subsequent pages after the first page is loaded. To do this, we want to observe the children
of the component (the loading text). If it is visible β meaning the user scrolled down to the end of the page β we want to load the next page. For this, we are going to need a reference to the element. This can be done by using useRef
with React.cloneElement
:
- import React, { useEffect, useState } from 'react'
+ import React, { useEffect, useRef, useState } from 'react'
const InfiniteScroll = ({
url,
limit,
render,
children
}) => {
+ const element = useRef(null)
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [page, setPage] = useState(1)
+ const [isLastPage, setIsLastPage] = useState(false)
const [endpoint, setEndpoint] = useState(`${url}?limit=${limit || 50}`)
+ const childWithRef = React.cloneElement(children, { ref: element })
...
return (
<React.Fragment>
{data && render(data)}
- {children}
+ {childWithRef}
</React.Fragment>
)
If you are passing a component as a child to InfiniteScroll
, you will need to use forwardRef
to pass the correct reference.
This way, now we are going to be able to access the DOM element directly from the component, without having to pass a ref
to children
. It's also time to add a new state to check if we are on the last page. We can use this to only observe the element if there are still pages. To observe this element, we want to add the following to our useEffect
:
const intersectionObserver = new IntersectionObserver(async entries => {
const entry = entries[0]
if (entry.isIntersecting && !loading) {
// TODO - request the new page
}
})
useEffect(() => {
if (!data) {
setInitialData()
}
if (!isLastPage) {
intersectionObserver.observe(element.current)
}
return () => {
element.current && intersectionObserver.unobserve(element.current)
}
}, [loading, page, endpoint])
return (
<React.Fragment>
{data && render(data)}
(/* Only show the loading if we are not on the last page */)
{!isLastPage && childWithRef}
</React.Fragment>
)
To create a new intersection observer, we can call IntersectionObserver
with the new
keyword, passing a callback function. In our case, as we are going to deal with network requests, we can make the callback async
. This function has access to the list of entries
that are intersecting with the viewport.
This will always return an array of elements, but since we are observing a single element, we can safely grab the first index. Each entry has an isIntersecting
property that will be true
whenever the element becomes visible in the viewport. Based on this and the loading
state, we can fire requests here for subsequent pages.
To start observing the element, we just need to call observe
on the instance of the IntersectionObserver
. We need to pass the element we want to observe, in our case, the loading indicator that we can access using element.current
(created by the useRef
hook).
Notice that the useEffect
hook also accepts a return function that will be called whenever the component is unmounted. In this case, we want to call unobserve
to stop observing the element. Now let's see what goes inside the if
statement exactly.
Requesting next pages
setLoading(true)
setPage(page + 1)
setEndpoint(getUrl(url, page, limit))
const response = await fetch(endpoint)
const json = await response.json()
setData([
...data,
...json[Object.keys(json)[0]]
])
setLoading(false)
if (json.total === json.skip + json.limit) {
setIsLastPage(true)
intersectionObserver.unobserve(element.current)
}
We want to start off by updating the state of the component: setting loading
to true
, increasing our page by 1
, and updating the URL. Here we are ready to do the same fetch
we did inside the setInitialData
function. Once the response is back, we can expand the data with the new dataset using the spread operator. This is essentially merging two arrays together. From here on, we can disable loading, and also add a logic for checking if we are on the last page.
DummyJSON provides a total
, skip
and limit
properties to each response. If the sum of skip
and limit
equals to the total
variable (which represents the total number of items across all pages), then we are at the very last page. This is the time when we want to remove the observer and hide the loading via the isLastPage
state.
Summary
And now you have a working example of an infinite scrolling component in React! There are also many popular libraries out there such as react-infinite-scroll-component
or react-infinite-scroller
that can be used for infinite scrolling, however, keep in mind when using third-party packages, that you will likely end up with more code than you actually need. Thank you for reading through, happy coding! π¨βπ»
Rocket Launch Your Career
Speed up your learning progress with our mentorship program. Join as a mentee to unlock the full potential of Webtips and get a personalized learning experience by experts to master the following frontend technologies: