Skip to content

Infinite Scroll

Implementing infinite scroll with automatic loading.

Basic Implementation

tsx
import { useInfiniteQuery } from '@tanstack/react-query'
import { useInView } from 'react-intersection-observer'
import { useEffect, useMemo } from 'react'
import { useTreaty } from './lib/treaty'

function InfiniteList() {
  const treaty = useTreaty()
  const { ref, inView } = useInView()

  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
    error
  } = useInfiniteQuery(
    treaty.api.posts.infiniteQueryOptions(
      { query: { limit: 20 } },
      {
        initialCursor: 0,
        getNextPageParam: (lastPage) => lastPage.nextCursor
      }
    )
  )

  // Flatten all pages into single array
  const allPosts = useMemo(
    () => data?.pages.flatMap(page => page.items) ?? [],
    [data]
  )

  // Auto-fetch when sentinel is in view
  useEffect(() => {
    if (inView && hasNextPage && !isFetchingNextPage) {
      fetchNextPage()
    }
  }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage])

  if (isLoading) return <div>Loading...</div>
  if (error) return <div>Error: {error.message}</div>

  return (
    <div>
      {allPosts.map(post => (
        <article key={post.id} className="post-card">
          <h2>{post.title}</h2>
          <p>{post.excerpt}</p>
        </article>
      ))}

      {/* Sentinel element */}
      <div ref={ref} className="sentinel">
        {isFetchingNextPage && <Spinner />}
        {!hasNextPage && allPosts.length > 0 && (
          <p>You've reached the end!</p>
        )}
      </div>
    </div>
  )
}

With Filters

tsx
function FilteredInfiniteList() {
  const treaty = useTreaty()
  const { ref, inView } = useInView()
  const [category, setCategory] = useState('all')
  const [sortBy, setSortBy] = useState('newest')

  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage
  } = useInfiniteQuery(
    treaty.api.posts.infiniteQueryOptions(
      {
        query: {
          category: category !== 'all' ? category : undefined,
          sortBy,
          limit: 20
        }
      },
      {
        initialCursor: 0,
        getNextPageParam: (lastPage) => lastPage.nextCursor
      }
    )
  )

  useEffect(() => {
    if (inView && hasNextPage && !isFetchingNextPage) {
      fetchNextPage()
    }
  }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage])

  const allPosts = data?.pages.flatMap(p => p.items) ?? []

  return (
    <div>
      {/* Filters */}
      <div className="filters">
        <select value={category} onChange={(e) => setCategory(e.target.value)}>
          <option value="all">All</option>
          <option value="tech">Tech</option>
          <option value="design">Design</option>
        </select>
        <select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
          <option value="newest">Newest</option>
          <option value="popular">Popular</option>
        </select>
      </div>

      {/* Posts */}
      {allPosts.map(post => (
        <article key={post.id}>{post.title}</article>
      ))}

      <div ref={ref}>
        {isFetchingNextPage && <Spinner />}
      </div>
    </div>
  )
}

Virtualized Infinite Scroll

For large lists, combine with virtualization:

tsx
import { useVirtualizer } from '@tanstack/react-virtual'

function VirtualizedInfiniteList() {
  const treaty = useTreaty()
  const parentRef = useRef(null)

  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
    useInfiniteQuery(
      treaty.api.items.infiniteQueryOptions(
        { query: { limit: 50 } },
        {
          initialCursor: 0,
          getNextPageParam: (lastPage) => lastPage.nextCursor
        }
      )
    )

  const allItems = data?.pages.flatMap(p => p.items) ?? []

  const virtualizer = useVirtualizer({
    count: hasNextPage ? allItems.length + 1 : allItems.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 50,
    overscan: 5
  })

  const virtualItems = virtualizer.getVirtualItems()

  // Fetch more when near bottom
  useEffect(() => {
    const lastItem = virtualItems[virtualItems.length - 1]
    if (!lastItem) return

    if (
      lastItem.index >= allItems.length - 1 &&
      hasNextPage &&
      !isFetchingNextPage
    ) {
      fetchNextPage()
    }
  }, [virtualItems, allItems.length, hasNextPage, isFetchingNextPage])

  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
        {virtualItems.map((virtualItem) => {
          const item = allItems[virtualItem.index]
          return (
            <div
              key={virtualItem.key}
              style={{
                position: 'absolute',
                top: 0,
                left: 0,
                width: '100%',
                height: `${virtualItem.size}px`,
                transform: `translateY(${virtualItem.start}px)`
              }}
            >
              {item ? item.title : 'Loading...'}
            </div>
          )
        })}
      </div>
    </div>
  )
}

Released under the Apache 2.0 License.