Skip to main content

Command Palette

Search for a command to run...

Infinite Scroll in React: The Smooth, Built-in Way with Intersection Observer (No Libraries Needed)

Hook into the browser’s native Intersection Observer API to seamlessly load paginated products, keep your users scrolling, not clicking.

Updated
6 min read
Infinite Scroll in React: The Smooth, Built-in Way with Intersection Observer (No Libraries Needed)
A
I am a web developer

Introduction: Why Infinite Scroll Matters: Keeping Users Engaged

You know that feeling? You’re scrolling through a feed, lost in the content, new content keeps appearing, No clunky “Load More” button. No jarring page refreshes. Just… more. That’s infinite scroll. And today, we’re wiring it up in React using the browser’s built-in API: the Intersection Observer API.

We’ll be pulling in real product data from dummyjson.com/products, this endpoint is perfect for testing and prototyping, and it uses limit and skip parameters for pagination. We’ll use exactly that to fetch our data in neat, manageable chunks.


You’re on Part 3 of our series on the Intersection Observer API.

Here we’ll build infinite scroll, but first, check out Parts 1 and 2 to get familiar with the API.

  1. Part 1: JavaScript Intersection Observer API — Master Scroll-Triggered Animations

  2. Part 2: How to Use the Intersection Observer API in React — Lazy Load Images & Components


    Let’s start by creating a reusable Hook: useInfiniteScroll.

    First, we create a custom hook. Why? Because you’ll want to reuse this magic everywhere. This hook watches an element. When that element scrolls into view, it triggers a function to fetch more data.

    Here’s the goods:

import React from "react";
type Props = { fetchData: () => void, hasMore: boolean };
export const useInfiniteScroll = ({ fetchData, hasMore }: Props) => {
 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 };
};

What's happening here?

  • fetchData: The function that fetches your next batch of data (like our products from dummyjson.com).

  • hasMore: A simple boolean flag. Are there more items to load from the server? If not, we stop fetching.

  • loadMoreRef: This is our sentinel element. We attach it to the bottom of our list. When this element becomes visible in the viewport, our Intersection Observer triggers the "Fetch more!" action.

  • The useEffect creates the IntersectionObserver when the component mounts and cleans it up when it unmounts, keeping everything tidy.


    Implementing the Product card and the Products List

    Let’s start by creating the Product type:

      export type ProductType = {
       id: number,
       thumbnail: string,
       title: string,
       price: number,
      };
    

    Then create a reusable component Product Card, to display each item (product) we pull from the API:

import React from "react";
import type { ProductType } from "../types";

type ProductCardProps = Pick<ProductType, "thumbnail" | "title" | "price">;

export default function ProductCard({
 thumbnail,
 title,
 price,
}: ProductCardProps) {
 return (
 <div className="product-card">
 <img
 src={thumbnail}
 alt={title}
 loading="lazy"
 className="product-card__image"
 />
 <h3 className="product-card__title" itemProp="name">
 {title}
 </h3>
 <p
 className="product-card__price"
 itemProp="offers"
 itemScope
 itemType="https://schema.org/Offer"
 >
 <strong itemProp="priceCurrency" content="USD">
 $
 </strong>
 <strong itemProp="price">{price.toFixed(2)}</strong>
 </p>
 </div>
 );
}

For the CSS, add these CSS to styles.css file in the route of our project


.product-card {  height: 350px;  padding: 20px;  background-color: #f8f9fa;  border-radius: 8px;  border: 2px solid #a29a9c;  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);  display: flex;  flex-direction: column;  align-items: center;  justify-content: space-between;}
.product-card__image {  width: 100%;  height: 200px;  object-fit: cover;  border-radius: 4px;  margin-bottom: 10px;}
.product-card__title {  margin: 10px 0;  font-size: 1rem;  text-align: center;}
.product-card__price {  margin: 0;  font-size: 1.2rem;  color: #e91e63;}

Now, let's put our hook to work. We'll build a Products component that fetches items from the dummyjson.com/products

API and renders them in a list. Each product is rendered in a card with a title, thumbnail, price, and reviews, perfect for a rich, engaging feed.

import React from "react";
import { ProductType } from "../types";
import { useInfiniteScroll } from "../hooks/use-infinite-scroll";
import ProductCard from "./product-card";
import LoadMoreElement from "./load-more-element";
import { delay } from "../types/utils/delay";

const ITEMS_PER_PAGE = 6;

export default function Products() {
 const [products, setProducts] = React.useState<ProductType[]>([]);
 const [hasMore, setHasMore] = React.useState<boolean>(true);
 const [page, setPage] = React.useState<number>(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();

 await delay(2000); //delay for 2sec

 // Slowdown timer

 if (data.products.length === 0) {
 setHasMore(false);
 } else {
 setProducts((prevProducts) => [...prevProducts, ...data.products]);
 setPage((prevPage) => prevPage + 1);
 }
 }, [page]);

 // Plug in our custom hook.
 const { loadMoreRef } = useInfiniteScroll(fetchProducts, hasMore);

 return (
 <>
 <div className="products">
 {products.map((product: ProductType) => (
 <ProductCard
 key={product.id}
 thumbnail={product.thumbnail}
 title={product.title}
 price={product.price}
 />
 ))}
 </div>
 {/* This is our sentinel. The hook watches this div. */}
 {hasMore && <LoadMoreElement loadMoreRef={loadMoreRef} />}
 </>
 );
}

Update the styles.css, add these CSS

.products {  margin: 20px;  display: grid;  gap: 20px;  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));}

Breaking it down:

  • We manage three essential state variables: the products array to store our items, the hasMore flag to track if additional data exists, and the current page for pagination.

  • The fetchProducts function handles our data retrieval. It calculates the skip parameter (page * ITEMS_PER_PAGE) for the API request, pulling the next set of products from dummyjson.com. When it receives an empty array, it sets hasMore to false to stop further requests.

  • We feed fetchProducts and hasMore into our custom useInfiniteScroll hook.

  • The hook returns loadMoreRef, which we attach to a small &lt;div&gt; at the bottom of our product list. When this sentinel element becomes visible as the user scrolls down, it automatically triggers fetchProducts—creating that seamless infinite scroll experience.

  • We have added a slowdown function: delay() just to slow down the loading process, so we can see the load more element in action, you can disable it.


Implementing the Load-more component

Now let’s create a dedicated, reusable component to handle our loading state. This isn’t just a blank <div> anymore, it’s a visual signal to the user that magic is happening.

Create a new file: components/Load-more-element.tsx

import React from "react";

type Props = {
 loadMoreRef: React.MutableRefObject<HTMLDivElement | null>;
};

export default function LoadMoreElement({ loadMoreRef }: Props) {
 return (
 <div className="load-more__container">
 <div ref={loadMoreRef} className="load-more">
 <span>Loading more</span>
 <div className="load-more__dots">
 <span />
 <span />
 <span />
 </div>
 </div>
 </div>
 );
}

Add some CSS to our styles.css file

/* Load more styles */.load-more {  display: flex;  flex-direction: row;  gap: 10px;  align-items: center;  justify-content: center;  padding: 16px;  margin: 10px auto;  width: 180px;  background: #e0e0e0;  color: #555;  border-radius: 6px;  cursor: default;  font-weight: normal;  transition: none;}
.load-more:hover {  background: #e0e0e0;  transform: none;}
.load-more__dots {  display: flex;  justify-content: center;  margin-top: 8px;  gap: 5px;}
.load-more__dots span {  width: 5px;  height: 5px;  background: #999;  border-radius: 50%;  display: inline-block;  animation: bounce 1s infinite;}
.load-more__dots span:nth-child(2) {  animation-delay: 0.2s;}
.load-more__dots span:nth-child(3) {  animation-delay: 0.4s;}
@keyframes bounce {  0%,  80%,  100% {    transform: translateY(0);  }  40% {    transform: translateY(-6px);  }}

Live Demo & Source Code

Live demo: https://dn6nv4-5173.csb.app/

Source code: https://github.com/adelpro/products-infinite-scroll


Conclusion: Infinite Scroll, the Native Way

And that’s it—you’ve just built a fully working infinite scroll in React using nothing but the browser’s built-in Intersection Observer API. No heavy libraries, no hacks, just smooth, native performance.

This technique isn’t just for product feeds. You can apply it to:

  • Blog posts: load article previews endlessly, keeping readers engaged.

  • Social feeds: keep the timeline alive without page reloads.

  • Image galleries: stream in photos as users scroll.

  • Dashboards: fetch logs, analytics, or messages chunk by chunk.

  • E-commerce: show products, reviews, or search results without friction.

Next time you’re tempted to reach for another dependency, remember: the browser already has your back. Keep scrolling, keep it smooth.


Cover image credit: Marten Bjork

More from this blog

L

Let's Code

34 posts

Welcome to "Let's Code," a blog dedicated to web development, where I share insights, tutorials, and experiences to help you enhance your skills and stay updated with the latest industry trends.