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

## 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**](https://peerlist.io/adelpro/articles/javascript-intersection-observer-api-master-animations--opti)
    
2. [**Part 2: How to Use the Intersection Observer API in React — Lazy Load Images & Components**](https://peerlist.io/adelpro/articles/how-to-use-the-intersection-observer-api-in-react-lazy-load-)
    
    ---
    
    ## 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:
    

```typescript
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:
    
    ```typescript
    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:
    

```typescript
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

```css

.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.

```typescript
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

```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`

```javascript
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

```css
/* 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/](https://dn6nv4-5173.csb.app/)

Source code: [https://github.com/adelpro/products-infinite-scroll](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](https://unsplash.com/@martenbjork)
