Infinite scrolling is a powerful user experience technique where more content loads as the user scrolls down. This guide will walk you through implementing infinite scroll in a React app with TypeScript, using the Intersection Observer API for efficient detection when more content needs to load.
Step 1: Setting Up the Project
We’ll assume you already have a basic React app setup. For this tutorial, we’ll focus on fetching paginated products from a sample API and loading more products as the user scrolls.
Step 2: Creating the useInfiniteScroll Custom Hook
The first step in implementing infinite scrolling is to create a reusable hook that detects when the user has scrolled near the end of the content. This hook will use the Intersection Observer API to trigger data fetching when a target element becomes visible.
Here’s our useInfiniteScroll
hook:
import React from 'react'
export const useInfiniteScroll = (fetchData: () => void, hasMore: boolean) => {
const loadMoreRef = React.useRef<HTMLDivElement | null>(null)
const handleIntersection = React.useCallback(
(entries: IntersectionObserverEntry[]) => {
const isIntersecting = entries[0]?.isIntersecting
if (isIntersecting && hasMore) {
fetchData()
}
},
[fetchData, hasMore]
)
React.useEffect(() => {
const observer = new IntersectionObserver(handleIntersection)
if (loadMoreRef.current) {
observer.observe(loadMoreRef.current)
}
return () => observer.disconnect()
}, [handleIntersection])
return { loadMoreRef }
}
Explanation:
-
fetchData
: A callback function that fetches the next set of data. -
hasMore
: A boolean that indicates if there’s more data to load. -
loadMoreRef
: A ref for the "Load More" target element, which will be observed by the Intersection Observer.
handleIntersection
: This callback is triggered by the Intersection Observer. It checks if the observed element is in view and if there’s more data to load, callingfetchData()
to retrieve more content if both conditions are met.useEffect
: Creates an Intersection Observer instance on mount to observe loadMoreRef, and disconnects the observer on cleanup.
This hook returns the loadMoreRef
, which we’ll attach to a "Load More" div in our component.
Step 3: Building the Products Component
Now that we have the useInfiniteScroll
hook, let’s create the Products
component. This component fetches products from an API, tracks the current page, and uses the hook to load more products as needed.
import React from 'react'
import { useInfiniteScroll } from '../hooks/useInfiniteScroll'
import ProductCard from './ProductCard'
type Product = {
id: number
title: string
thumbnail: string
}
const ITEMS_PER_PAGE = 6
const Products: React.FC = () => {
const [products, setProducts] = React.useState<Product[]>([])
const [hasMore, setHasMore] = React.useState(true)
const [page, setPage] = React.useState(0)
const fetchProducts = React.useCallback(async () => {
const response = await fetch(
`https://dummyjson.com/products?limit=${ITEMS_PER_PAGE}&skip=${page * ITEMS_PER_PAGE}`
)
const data = await response.json()
if (data.products.length === 0) {
setHasMore(false)
} else {
setProducts((prevProducts) => [...prevProducts, ...data.products])
setPage((prevPage) => prevPage + 1)
}
}, [page])
const { loadMoreRef } = useInfiniteScroll(fetchProducts, hasMore)
return (
<div>
<div className="products">
{products.map(({ id, thumbnail, title }) => (
<ProductCard key={id} thumbnail={thumbnail} title={title} />
))}
</div>
{hasMore && <div ref={loadMoreRef}>Load More...</div>}
</div>
)
}
export default Products
Explanation:
-
products
: Holds the loaded products. -
hasMore
: Tracks if more products are available for fetching. -
page
: Tracks the current page to fetch the next set of products. -
fetchProducts
Function: Fetches the next set of products from the API. If no more products are available, hasMore is set to false. Otherwise, it appends the new products to the existing list and increments the page. Using useInfiniteScroll:
We pass fetchProducts
and hasMore
to useInfiniteScroll
.
The returned loadMoreRef
is attached to a div that acts as the scroll trigger.
Step 4: Creating the ProductCard Component
For a cleaner structure, we’ll create a ProductCard component to display each product’s details.
import React from 'react'
const ProductCard: React.FC<{ thumbnail: string; title: string }> = ({
thumbnail,
title,
}) => (
<span className="products__single">
<img src={thumbnail} alt={title} />
<span>{title}</span>
</span>
)
export default ProductCard
Step 5: Styling the Components
Here’s some basic CSS for styling the product grid and individual product cards.
.products {
margin: 20px;
padding: 0;
list-style-type: none;
display: grid;
gap: 20px;
grid-template-columns: 1fr 1fr 1fr;
}
.products__single {
height: 250px;
list-style: none;
padding: 20px;
background-color: rgb(220, 220, 220);
text-align: center;
border-radius: 5px;
}
.products__single > img {
width: 100%;
height: 95%;
object-fit: cover;
margin-bottom: 3px;
}
Step 6: Putting It All Together in the App Component
Finally, import and use the Products component in the main App component:
import React from 'react'
import './App.css'
import Products from './components/Products'
function App() {
return (
<div className="App">
<Products />
</div>
)
}
export default App
Conclusion
With this setup, you now have infinite scroll implemented using React, TypeScript, and the Intersection Observer API. As the user scrolls, more products will load dynamically, providing a smooth and engaging experience.
Top comments (3)
This is a naïve way to implement infinite scroll. Will quickly take up more and more memory. Better to remove post content that has scrolled off the top as well as adding new stuff to the bottom.
Do you have live example?
I built one years ago, and I remember it wasn't that easy to do (no longer online unfortunately). You can see the concept working in many places online though. They're usually removing content that has gone off the top of the screen - often with a certain amount of 'buffer', and redrawing it if you scroll back up (scroll down a long way, then use 'home' on the keyboard to jump back to the top - you'll see there is nothing there initially, then it gets redrawn. You can normally confirm this in the HTML in the developer tools).
Doing it this way will mean the browser has way less to do and keep track of - improving the responsiveness, performance, and resource usage of your site.
IIRC, preventing layout shifts and maintaining scrollbar height and position were the most challenging parts.
Try screen grabbing Instagram (full page) after having scrolled down a lot - you will see plenty of blank space towards the top of the scroll area in the screen grab.