Skip to main content
Back to Blog
Tutorials
3 min read
December 26, 2024

How to Build an Infinite Loading List in React

Implement infinite loading in React using Intersection Observer with cursor-based pagination, loading states, and virtualization for performance.

Ryel Banfield

Founder & Lead Developer

Infinite loading replaces pagination with auto-fetching as users scroll. Here is how to build it well.

useInfiniteScroll Hook

// hooks/useInfiniteScroll.ts
"use client";

import { useCallback, useEffect, useRef, useState } from "react";

interface UseInfiniteScrollOptions<T> {
  fetchFn: (cursor: string | null) => Promise<{ items: T[]; nextCursor: string | null }>;
  initialCursor?: string | null;
}

export function useInfiniteScroll<T>({
  fetchFn,
  initialCursor = null,
}: UseInfiniteScrollOptions<T>) {
  const [items, setItems] = useState<T[]>([]);
  const [cursor, setCursor] = useState<string | null>(initialCursor);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const observerRef = useRef<IntersectionObserver | null>(null);
  const loadingRef = useRef(false);

  const loadMore = useCallback(async () => {
    if (loadingRef.current || !hasMore) return;
    loadingRef.current = true;
    setLoading(true);
    setError(null);

    try {
      const result = await fetchFn(cursor);
      setItems((prev) => [...prev, ...result.items]);
      setCursor(result.nextCursor);
      setHasMore(result.nextCursor !== null);
    } catch (err) {
      setError(err instanceof Error ? err.message : "Failed to load");
    } finally {
      setLoading(false);
      loadingRef.current = false;
    }
  }, [cursor, hasMore, fetchFn]);

  const sentinelRef = useCallback(
    (node: HTMLElement | null) => {
      if (observerRef.current) observerRef.current.disconnect();

      observerRef.current = new IntersectionObserver(
        (entries) => {
          if (entries[0].isIntersecting) {
            loadMore();
          }
        },
        { rootMargin: "200px" }
      );

      if (node) observerRef.current.observe(node);
    },
    [loadMore]
  );

  // Initial load
  useEffect(() => {
    if (items.length === 0) loadMore();
  }, []); // eslint-disable-line react-hooks/exhaustive-deps

  const reset = useCallback(() => {
    setItems([]);
    setCursor(initialCursor);
    setHasMore(true);
    setError(null);
  }, [initialCursor]);

  return { items, loading, hasMore, error, sentinelRef, reset };
}

API Route with Cursor Pagination

// app/api/posts/route.ts
import { NextRequest, NextResponse } from "next/server";

interface Post {
  id: string;
  title: string;
  excerpt: string;
  date: string;
  author: string;
}

export async function GET(request: NextRequest) {
  const cursor = request.nextUrl.searchParams.get("cursor");
  const limit = parseInt(request.nextUrl.searchParams.get("limit") ?? "20", 10);

  // In production: use database cursor pagination
  // const posts = await db
  //   .select()
  //   .from(postsTable)
  //   .where(cursor ? gt(postsTable.id, cursor) : undefined)
  //   .orderBy(postsTable.id)
  //   .limit(limit + 1);

  const allPosts = generateMockPosts();
  const startIndex = cursor ? allPosts.findIndex((p) => p.id === cursor) + 1 : 0;
  const slice = allPosts.slice(startIndex, startIndex + limit + 1);

  const hasMore = slice.length > limit;
  const items = hasMore ? slice.slice(0, limit) : slice;
  const nextCursor = hasMore ? items[items.length - 1].id : null;

  return NextResponse.json({ items, nextCursor });
}

function generateMockPosts(): Post[] {
  return Array.from({ length: 100 }, (_, i) => ({
    id: `post-${i + 1}`,
    title: `Post number ${i + 1}`,
    excerpt: `This is the excerpt for post ${i + 1}.`,
    date: new Date(2026, 0, i + 1).toISOString(),
    author: "Ryel Banfield",
  }));
}

Infinite Post List Component

// components/InfinitePostList.tsx
"use client";

import { useInfiniteScroll } from "@/hooks/useInfiniteScroll";

interface Post {
  id: string;
  title: string;
  excerpt: string;
  date: string;
  author: string;
}

async function fetchPosts(cursor: string | null) {
  const params = new URLSearchParams({ limit: "20" });
  if (cursor) params.set("cursor", cursor);

  const res = await fetch(`/api/posts?${params}`);
  if (!res.ok) throw new Error("Failed to load posts");
  return res.json() as Promise<{ items: Post[]; nextCursor: string | null }>;
}

export function InfinitePostList() {
  const { items, loading, hasMore, error, sentinelRef } =
    useInfiniteScroll<Post>({ fetchFn: fetchPosts });

  return (
    <div className="max-w-2xl mx-auto space-y-4">
      {items.map((post) => (
        <article key={post.id} className="border rounded-lg p-4">
          <h2 className="text-lg font-semibold">{post.title}</h2>
          <p className="text-sm text-muted-foreground mt-1">{post.excerpt}</p>
          <div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
            <span>{post.author}</span>
            <span>·</span>
            <time>{new Date(post.date).toLocaleDateString()}</time>
          </div>
        </article>
      ))}

      {/* Loading skeletons */}
      {loading && (
        <div className="space-y-4">
          {Array.from({ length: 3 }, (_, i) => (
            <div key={i} className="border rounded-lg p-4 animate-pulse">
              <div className="h-5 bg-muted rounded w-3/4" />
              <div className="h-4 bg-muted rounded w-full mt-2" />
              <div className="h-3 bg-muted rounded w-1/3 mt-2" />
            </div>
          ))}
        </div>
      )}

      {/* Error state */}
      {error && (
        <div className="text-center py-4">
          <p className="text-red-600 text-sm">{error}</p>
          <button
            onClick={() => window.location.reload()}
            className="text-sm text-primary underline mt-1"
          >
            Try again
          </button>
        </div>
      )}

      {/* Sentinel element */}
      {hasMore && !error && (
        <div ref={sentinelRef} className="h-1" aria-hidden="true" />
      )}

      {/* End of list */}
      {!hasMore && items.length > 0 && (
        <p className="text-center text-sm text-muted-foreground py-4">
          You have reached the end.
        </p>
      )}
    </div>
  );
}

With Filters

"use client";

import { useCallback, useState } from "react";
import { useInfiniteScroll } from "@/hooks/useInfiniteScroll";

export function FilteredInfiniteList() {
  const [category, setCategory] = useState<string>("all");

  const fetchFn = useCallback(
    async (cursor: string | null) => {
      const params = new URLSearchParams({ limit: "20" });
      if (cursor) params.set("cursor", cursor);
      if (category !== "all") params.set("category", category);

      const res = await fetch(`/api/posts?${params}`);
      return res.json();
    },
    [category]
  );

  const { items, loading, hasMore, sentinelRef, reset } = useInfiniteScroll({
    fetchFn,
  });

  function handleCategoryChange(newCategory: string) {
    setCategory(newCategory);
    reset(); // Clear items and reload with new filter
  }

  return (
    <div>
      <div className="flex gap-2 mb-4">
        {["all", "tech", "design", "business"].map((cat) => (
          <button
            key={cat}
            onClick={() => handleCategoryChange(cat)}
            className={`px-3 py-1 rounded text-sm ${
              category === cat ? "bg-primary text-primary-foreground" : "bg-muted"
            }`}
          >
            {cat}
          </button>
        ))}
      </div>
      {/* ... render items, sentinel, etc. */}
    </div>
  );
}

Need Performant Data-Heavy Interfaces?

We build fast, scalable frontends that handle large datasets smoothly. Contact us to discuss your project.

infinite scrollintersection observerpaginationReacttutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles