Skip to content
Go back

Handling Infinite Scroll with Intersection Observer in React

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

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.


Share this post on:

Previous Post
How to Implement React Suspense with Data Fetching
Next Post
Using React Query with GraphQL for Optimized Fetching