Handling Infinite Scroll with Intersection Observer in React
Introduction
Infinite scroll improves user experience by loading content progressively. This guide shows how to implement it efficiently using the Intersection Observer API.
Prerequisites
- React project setup
- Basic understanding of React hooks
Step 1: Create Custom Hook
Create hooks/useIntersectionObserver.ts
:
import { useEffect, useRef, useState } from "react";
interface UseIntersectionObserverProps {
threshold?: number;
root?: Element | null;
rootMargin?: string;
}
export function useIntersectionObserver({
threshold = 0.1,
root = null,
rootMargin = "0px",
}: UseIntersectionObserverProps = {}) {
const [entry, setEntry] = useState<IntersectionObserverEntry | null>(null);
const elementRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const element = elementRef.current;
if (!element) return;
const observer = new IntersectionObserver(
([entry]) => setEntry(entry),
{ threshold, root, rootMargin }
);
observer.observe(element);
return () => {
observer.disconnect();
};
}, [threshold, root, rootMargin]);
return [elementRef, entry] as const;
}
Step 2: Create Infinite Scroll Hook
Create hooks/useInfiniteScroll.ts
:
import { useState, useEffect, useCallback } from "react";
import { useIntersectionObserver } from "./useIntersectionObserver";
interface UseInfiniteScrollProps<T> {
fetchData: (page: number) => Promise<T[]>;
initialPage?: number;
}
export function useInfiniteScroll<T>({
fetchData,
initialPage = 1,
}: UseInfiniteScrollProps<T>) {
const [items, setItems] = useState<T[]>([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [page, setPage] = useState(initialPage);
const [error, setError] = useState<string | null>(null);
const [sentinelRef, entry] = useIntersectionObserver({
threshold: 0.1,
rootMargin: "100px",
});
const loadMore = useCallback(async () => {
if (loading || !hasMore) return;
try {
setLoading(true);
setError(null);
const newItems = await fetchData(page);
if (newItems.length === 0) {
setHasMore(false);
} else {
setItems(prev => [...prev, ...newItems]);
setPage(prev => prev + 1);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load data");
} finally {
setLoading(false);
}
}, [fetchData, page, loading, hasMore]);
// Load more when sentinel becomes visible
useEffect(() => {
if (entry?.isIntersecting) {
loadMore();
}
}, [entry?.isIntersecting, loadMore]);
// Load initial data
useEffect(() => {
loadMore();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return {
items,
loading,
hasMore,
error,
sentinelRef,
loadMore,
};
}
Step 3: Create Post Component
Create components/PostCard.tsx
:
interface Post {
id: number;
title: string;
body: string;
userId: number;
}
interface PostCardProps {
post: Post;
}
export default function PostCard({ post }: PostCardProps) {
return (
<div className="bg-white rounded-lg shadow p-6 mb-4">
<h2 className="text-xl font-semibold mb-2">{post.title}</h2>
<p className="text-gray-600 mb-2">{post.body}</p>
<span className="text-sm text-gray-500">User ID: {post.userId}</span>
</div>
);
}
Step 4: Implement Infinite Scroll Component
Create components/PostsList.tsx
:
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll';
import PostCard from './PostCard';
interface Post {
id: number;
title: string;
body: string;
userId: number;
}
// Mock API function
const fetchPosts = async (page: number): Promise<Post[]> => {
const response = await fetch(
`https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=10`
);
if (!response.ok) {
throw new Error('Failed to fetch posts');
}
return response.json();
};
export default function PostsList() {
const {
items: posts,
loading,
hasMore,
error,
sentinelRef,
} = useInfiniteScroll<Post>({
fetchData: fetchPosts,
});
return (
<div className="max-w-2xl mx-auto p-4">
<h1 className="text-3xl font-bold mb-8">Posts</h1>
<div>
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
{/* Sentinel element for intersection observer */}
<div ref={sentinelRef} className="h-10 flex items-center justify-center">
{loading && (
<div className="flex items-center space-x-2">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span>Loading more posts...</span>
</div>
)}
{error && (
<div className="text-red-600 text-center">
Error: {error}
</div>
)}
{!hasMore && !loading && (
<div className="text-gray-500 text-center">
No more posts to load
</div>
)}
</div>
</div>
);
}
Step 5: Usage in App
import PostsList from '@/components/PostsList';
export default function Home() {
return (
<main>
<PostsList />
</main>
);
}
Summary
Using Intersection Observer for infinite scroll provides better performance than scroll event listeners, automatically handles viewport detection, and improves user experience with smooth content loading.