Infinite scrolling loads more content as the user scrolls down, replacing traditional pagination. The core technique uses IntersectionObserver to detect when the user reaches the end of the list.
Basic implementation with IntersectionObserver:
1import { useState, useEffect, useRef, useCallback } from "react";23interface Item {4 id: number;5 title: string;6}78function InfiniteList() {9 const [items, setItems] = useState<Item[]>([]);10 const [loading, setLoading] = useState(false);11 const [hasMore, setHasMore] = useState(true);12 const observerRef = useRef<IntersectionObserver | null>(null);13 const lastItemRef = useRef<HTMLDivElement>(null);1415 const loadMore = useCallback(async () => {16 if (loading || !hasMore) return;17 setLoading(true);18 try {19 const newItems = await fetchItems(items.length);20 setItems(prev => [...prev, ...newItems]);21 setHasMore(newItems.length > 0);22 } finally {23 setLoading(false);24 }25 }, [items.length, loading, hasMore]);2627 useEffect(() => {28 observerRef.current = new IntersectionObserver(29 (entries) => {30 if (entries[0].isIntersecting) {31 loadMore();32 }33 },34 { threshold: 0.1 }35 );3637 if (lastItemRef.current) {38 observerRef.current.observe(lastItemRef.current);39 }4041 return () => observerRef.current?.disconnect();42 }, [loadMore]);4344 return (45 <div>46 {items.map((item, index) => (47 <div48 key={item.id}49 ref={index === items.length - 1 ? lastItemRef : null}50 style={{ padding: "16px", borderBottom: "1px solid #eee" }}51 >52 {item.title}53 </div>54 ))}55 {loading && <div className="spinner">Loading more...</div>}56 {!hasMore && <p>No more items</p>}57 </div>58 );59}
With TanStack Query (recommended for production):
1import { useInfiniteQuery } from "@tanstack/react-query";2import { useRef, useCallback } from "react";34function InfiniteList() {5 const observer = useRef<IntersectionObserver>();67 const {8 data,9 fetchNextPage,10 hasNextPage,11 isFetchingNextPage,12 isLoading,13 } = useInfiniteQuery({14 queryKey: ["items"],15 queryFn: ({ pageParam }) => fetchItems(pageParam),16 initialPageParam: 0,17 getNextPageParam: (lastPage) => lastPage.nextCursor,18 });1920 const lastItemRef = useCallback(21 (node: HTMLDivElement | null) => {22 if (isFetchingNextPage) return;23 if (observer.current) observer.current.disconnect();24 observer.current = new IntersectionObserver(entries => {25 if (entries[0].isIntersecting && hasNextPage) {26 fetchNextPage();27 }28 });29 if (node) observer.current.observe(node);30 },31 [isFetchingNextPage, hasNextPage, fetchNextPage]32 );3334 if (isLoading) return <Spinner />;3536 return (37 <div>38 {data?.pages.map((page, i) => (39 <div key={i}>40 {page.items.map((item: Item) => (41 <div key={item.id}>{item.title}</div>42 ))}43 </div>44 ))}45 <div ref={lastItemRef} />46 {isFetchingNextPage && <Spinner />}47 </div>48 );49}
When to use:
When NOT to use: