Infinite scrolling is a powerful technique that improves user experience by loading content dynamically. Whether you’re building a social feed, a product listing page, or a blog archive, infinite scroll provides a seamless browsing experience by loading more data as the user scrolls — without them ever having to click a “Next” button.
This technique improves engagement, performance, and user satisfaction because it loads content progressively, reducing the initial payload and keeping users in flow.
In this guide, we’ll look at three ways to implement infinite scroll in React:
-
Custom Hook with Intersection Observer (React-only)
-
React Query’s useInfiniteQuery
with Intersection Observer
-
React Query’s useInfiniteQuery
with react-intersection-observer
Hook
Initial Setup
To begin, we need to first create a react app using vite and add tailwind css
for styling. After installing react, open the folder for the new app and create an api folde
r inside the src
directory. We will be using axios
to fetch data from jsonplaceholder.typicode.com/posts.
/* /src/api/posts.ts */
import axios, { type AxiosRequestConfig } from "axios";
export const api = axios.create({
baseURL: "https://jsonplaceholder.typicode.com",
});
export interface Post {
id: number;
userId: number;
title: string;
body: string;
}
/**
* Fetch a single page of posts
* @param pageParam - The page number to fetch (default: 1)
* @param options - Optional Axios request config
* @returns An array of Post objects
*/
export const getPostsPage = async (
pageParam: number = 1,
options: AxiosRequestConfig = {}
): Promise<Post[]> => {
const response = await api.get<Post[]>(`/posts?_page=${pageParam}`, options);
return response.data;
};
Next, we need to create a component that will render the blog post. You can create a types folder
to store the blog post type. We can name this component PostCard.
/* /src/components/postcard */
import React from "react";
import { User, MessageCircle, ChevronRight } from "lucide-react";
import { type Post } from "../types";
interface PostCardProps {
post: Post;
}
export const PostCard = React.forwardRef<HTMLDivElement, PostCardProps>(
({ post }, ref) => {
return (
<div
ref={ref}
className="bg-white rounded-xl shadow-md hover:shadow-xl
transition-all duration-300 border border-gray-100 overflow-hidden group">
<div className="p-6">
{/* Header with user info */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2 text-gray-500">
<User className="h-4 w-4" />
<span className="text-sm font-medium">User {post.userId}</span>
</div>
<div className="flex items-center space-x-1 text-gray-400">
<MessageCircle className="h-4 w-4" />
<span className="text-sm">Post #{post.id}</span>
</div>
</div>
{/* Title */}
<h3 className="text-xl font-bold text-gray-900 mb-3 leading-tight
group-hover:text-blue-600 transition-colors duration-200 capitalize">
{post.title}
</h3>
{/* Body */}
<p className="text-gray-600 leading-relaxed line-clamp-3">
{post.body}
</p>
{/* Read more button */}
<div className="mt-4 pt-4 border-t border-gray-100">
<button className="text-blue-600 hover:text-blue-800 font-medium
text-sm transition-colors duration-200 inline-flex items-center space-x-1 group cursor-pointer">
Read more
<ChevronRight className="size-3.5 transform group-hover:translate-x-1 transition-transform duration-200" />
</button>
</div>
</div>
</div>
);
}
);
PostCard.displayName = "PostCard";
1️⃣ Custom Hook with Intersection Observer (React-only)
Now that we have everything in place, our first example will involve using a custom hook to fetch data dynamically based on the intersection observer. This option could be very helpful for a small project since it does not require any extra packages.
Custom Hook – usePosts.ts
/* /src/hooks/use-posts.ts */
import { useState, useEffect } from "react";
import { type Post } from "../types";
import { getPostsPage } from "../api/posts";
interface UsePostsReturn {
isLoading: boolean;
isError: boolean;
error: { message?: string };
results: Post[];
hasNextPage: boolean;
}
const usePosts = (pageNum: number = 1): UsePostsReturn => {
const [results, setResults] = useState<Post[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [error, setError] = useState<{ message?: string }>({});
const [hasNextPage, setHasNextPage] = useState(false);
useEffect(() => {
setIsLoading(true);
setIsError(false);
setError({});
const controller = new AbortController();
const { signal } = controller;
getPostsPage(pageNum, { signal })
.then((data) => {
setResults((prev) => [...prev, ...data]);
setHasNextPage(Boolean(data.length));
setIsLoading(false);
})
.catch((e) => {
setIsLoading(false);
if (signal.aborted) return;
setIsError(true);
setError({ message: e.message });
});
return () => controller.abort();
}, [pageNum]);
return { isLoading, isError, error, results, hasNextPage };
};
export default usePosts;
This custom hook gives us the isLoading
state, isError
state, the error from the api
, the results or data and whether there is a NextPage so we can continue fetching. Now, we need to call this hook and render the results in a grid that displays the PostCard component. We will call this Example1
.
/*Use vanilla react with intersection observer and custom hook */
import { useState, useRef, useCallback } from "react";
import usePosts from "../hooks/use-posts";
import type { Post } from "../types";
import { PostCard } from "./postcard";
import { Loader2 } from "lucide-react";
const Example1 = () => {
const [pageNum, setPageNum] = useState(1);
const { isLoading, isError, error, results, hasNextPage } = usePosts(pageNum);
const intObserver = useRef<IntersectionObserver | null>(null);
const lastPostRef = useCallback(
(post: HTMLElement | null) => {
if (isLoading) return;
if (intObserver.current) intObserver.current.disconnect();
intObserver.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasNextPage) {
console.log("We are near the last post!");
setPageNum((prev) => prev + 1);
}
});
if (post) intObserver.current.observe(post);
},
[isLoading, hasNextPage]
);
if (isError) return <p className="center">Error: {error.message}</p>;
const content = results.map((post: Post, i: number) => {
if (results.length === i + 1) {
return <PostCard ref={lastPostRef} key={post.id} post={post} />;
}
return <PostCard key={post.id} post={post} />;
});
return (
<div className="container mx-auto px-4 py-16">
<h1
id="top"
className="text-2xl font-bold text-gray-900 mb-4 text-center">
React Infinite Scroll – Example 1
</h1>
<div className="grid grid-cols-1 gap-6"> {content}</div>
{isLoading && (
<div className="flex items-center space-x-2 py-2 justify-center">
<Loader2 className="size-4 animate-spin" />{" "}
<span>Loading More Posts..</span>
</div>
)}
<div className="text-center py-2">
<a href="#top" className="hover:text-blue-500 hover:underline">
↑ Back to Top
</a>
</div>
</div>
);
};
export default Example1;
Use this method if:
- You’re not in a rush and can dedicate time to building and maintaining the solution
- You need a highly customized infinite scroll implementation
- You want to avoid adding external dependencies to your project
2️⃣ React Query’s useInfiniteQuery
with Intersection Observer
If you’re already using React Query, infinite scrolling becomes even cleaner thanks to its useInfiniteQuery hook that handles everything for us. This example is great for a large project and is more preferrable since it is usually ideal for a production ready build.
We’ll still use the DOM’s Intersection Observer API to detect when to load the next page. We will name this component Example2
.
/*Use infinite-query with intersection observer */
import { useInfiniteQuery } from "@tanstack/react-query";
import { useRef, useCallback } from "react";
import { Loader2 } from "lucide-react";
import type { Post } from "../types";
import { getPostsPage } from "../api/posts";
import { PostCard } from "./postcard";
const Example2 = () => {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery<Post[]>({
queryKey: ["posts"],
queryFn: ({ pageParam = 1 }) => getPostsPage(pageParam as number),
getNextPageParam: (lastPage, allPages) =>
lastPage.length ? allPages.length + 1 : undefined,
initialPageParam: 1,
});
const intObserver = useRef<IntersectionObserver | null>(null);
const lastPostRef = useCallback(
(post: HTMLElement | null) => {
if (isFetchingNextPage) return;
if (intObserver.current) intObserver.current.disconnect();
intObserver.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasNextPage) {
fetchNextPage();
}
});
if (post) intObserver.current.observe(post);
},
[isFetchingNextPage, fetchNextPage, hasNextPage]
);
const content = data?.pages.flatMap((pg, pageIndex) =>
pg.map((post, i, arr) => {
const isLastCard =
pageIndex === data.pages.length - 1 &&
i === arr.length - 1 &&
hasNextPage;
return (
<PostCard
key={post.id}
post={post}
ref={isLastCard ? lastPostRef : undefined}
/>
);
})
);
return (
<div className="container mx-auto px-4 py-16">
<h1
id="top"
className="text-2xl font-bold text-gray-900 mb-4 text-center">
React Infinite Scroll – Example 2
</h1>
<div className="grid grid-cols-1 gap-6"> {content}</div>
{isFetchingNextPage && (
<div className="flex items-center space-x-2 py-2 justify-center">
<Loader2 className="size-4 animate-spin" />{" "}
<span>Loading More Posts..</span>
</div>
)}
<div className="text-center py-2">
<a href="#top" className="hover:text-blue-500 hover:underline">
↑ Back to Top
</a>
</div>
</div>
);
};
export default Example2;
Use this method if:
- You need a fast and easy solution
- Your app can accommodate additional dependencies
- You want to leverage community-tested code and ongoing support
3️⃣ React Query with react-intersection-observer
The last option is to replace browser intersection observer with react-intersection-observer to detect the last post in view. Although this will require installing a new package, it requires less boilerplate code, with better developer experience and readability. This also minimizes the risk of memory leaks if we forget to disconnect the observer. We will name this component as Example3
.
/*Use infinite-query with react-intersection-observer */
import { useInfiniteQuery } from "@tanstack/react-query";
import { useInView } from "react-intersection-observer";
import type { Post } from "../types";
import { getPostsPage } from "../api/posts";
import { PostCard } from "./postcard";
import { Loader2 } from "lucide-react";
const Example3 = () => {
const { ref, inView } = useInView();
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery<Post[]>({
queryKey: ["posts"],
queryFn: ({ pageParam = 1 }) => getPostsPage(pageParam as number),
getNextPageParam: (lastPage, allPages) =>
lastPage.length ? allPages.length + 1 : undefined,
initialPageParam: 1,
});
// Trigger fetch when last card is visible
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
const content = data?.pages.flatMap((pg, pageIndex) =>
pg.map((post, i, arr) => {
const isLastCard =
pageIndex === data.pages.length - 1 &&
i === arr.length - 1 &&
hasNextPage;
return (
<PostCard
key={post.id}
post={post}
ref={isLastCard ? ref : undefined}
/>
);
})
);
return (
<div className="container mx-auto px-4 py-16">
<h1
id="top"
className="text-2xl font-bold text-gray-900 mb-4 text-center">
React Infinite Scroll – Example 3
</h1>
<div className="grid grid-cols-1 gap-6"> {content}</div>
{isFetchingNextPage && (
<div className="flex items-center space-x-2 py-2 justify-center">
<Loader2 className="size-4 animate-spin" />{" "}
<span>Loading More Posts..</span>
</div>
)}
<div className="text-center py-2">
<a href="#top" className="hover:text-blue-500 hover:underline">
↑ Back to Top
</a>
</div>
</div>
);
};
export default Example3;
Final Thoughts
Infinite scroll eliminates the need for traditional pagination. Instead of navigating through many pages, users can scroll nonstop to view more content, making the experience more engaging and intuitive.
Infinite scroll is widely used in social media platforms like Instagram, X, and TikTok, enabling users to endlessly browse through feeds of images and videos without interruption.
-
Approach 1 gives you full control and works without extra libraries, but requires more boilerplate.
-
Approach 2 simplifies data fetching and pagination using React Query.
-
Approach 3 takes the simplification further by removing manual Intersection Observer setup with react-intersection-observer
.
If you’re already using React Query, go for Approach 2 or 3. If you want a lightweight, dependency-free option, Approach 1 is your best bet.
Resources