Create Infinite Scroll component

The Infinite Scroll pattern allows you to automatically load more content when the user reaches the bottom of a list, without manually clicking a "Load More" button.

For that, we will build a InfiniteScroll component that does the infinite logic and use the useIntersectionObserver hook.

LinkIconImplementation

ReactIconInfiniteScroll.tsx
import { ReactNode, useEffect } from 'react'
import { useIntersectionObserver } from './useIntersectionObserver'
 
type InfiniteScrollProps = {
  children: ReactNode
  hasNextPage: boolean
  onLoadMore: () => void
  threshold?: number
  isLoading?: boolean // prevents duplicate fetches
}
 
export function InfiniteScroll({
  children,
  hasNextPage,
  onLoadMore,
  threshold = 100,
  isLoading = false,
}: InfiniteScrollProps) {
  
  const [containerRef, isIntersecting] = useIntersectionObserver({
    rootMargin: `0px 0px ${threshold}px 0px`,
  })
 
  useEffect(() => {
    if (isIntersecting && hasNextPage && !isLoading) {
      onLoadMore()
    }
  }, [isIntersecting, hasNextPage, onLoadMore, isLoading])
 
  return (
    <div>
      {children}
      <div ref={containerRef} className="h-1" />
    </div>
  )
}

LinkIconUsage

ReactIcon
import { useState, useEffect, useCallback } from 'react'
import { InfiniteScroll } from './InfiniteScroll'
 
export default function PostsPage() {
  const [posts, setPosts] = useState<any[]>([])
  const [page, setPage] = useState(1)
  const [hasNextPage, setHasNextPage] = useState(true)
  const [loading, setLoading] = useState(false)
 
  const loadMore = useCallback(async () => {
    if (loading || !hasNextPage) return
    setLoading(true)
 
    const res = await fetch(`/api/posts?page=${page}`)
    const data = await res.json()
 
    setPosts(prev => [...prev, ...data.results])
    setHasNextPage(data.results.length > 0)
    setPage(prev => prev + 1)
    setLoading(false)
  }, [page, loading, hasNextPage])
 
  // initial load
  useEffect(() => {
    loadMore()
  }, [])
 
  return (
    <InfiniteScroll
      hasNextPage={!!hasNextPage}
      onLoadMore={fetchNextPage}
      threshold={300} // prefetch earlier with 300 than 100
      isLoading={loading} // avoid duplicate calls
    >
      {posts.map((post, index) => (
        <div key={index} className="p-4 border-b">
          <h2 className="font-bold">{post.title}</h2>
          <p>{post.body}</p>
        </div>
      ))}
      {loading && <p>Loading...</p>}
    </InfiniteScroll>
  )
}

LinkIconUsage with Tanstack InfinityQuery

ReactIcon
import { useInfiniteQuery } from '@tanstack/react-query'
import { InfiniteScroll } from './InfiniteScroll'
 
type Post = {
  id: number
  title: string
  body: string
}
 
async function fetchPosts({ pageParam = 1 }): Promise<{ results: Post[]; nextPage?: number }> {
  const res = await fetch(`/api/posts?page=${pageParam}`)
  if (!res.ok) throw new Error('Failed to fetch')
  return res.json()
}
 
export default function PostsPage() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    getNextPageParam: lastPage => lastPage.nextPage ?? undefined,
  })
 
  return (
    <InfiniteScroll hasNextPage={!!hasNextPage} onLoadMore={fetchNextPage} threshold={100} isLoading={isFetchingNextPage}>
      {data?.pages.flatMap(page =>
        page.results.map(post => (
          <div key={post.id} className="p-4 border-b">
            <h2 className="font-bold">{post.title}</h2>
            <p>{post.body}</p>
          </div>
        ))
      )}
      {isFetchingNextPage && <p>Loading...</p>}
    </InfiniteScroll>
  )
}