| | |

TanStack Query 11 ⚛️ Infinite Queries — Implementing load-more and scroll paging

⏱️ Estimated read: 13 min | |

Infinite queries are the TanStack Query mechanism for lists that grow by appending new pages to an existing set rather than replacing one page with another. The previous chapter covered pagination, where each page is a separate cache entry and the user navigates between them. Infinite queries are the opposite: a single cache entry holds an array of pages, and the “load more” button or the scroll position triggers a fetch that appends the next page to that array . The result is a feed, a comment thread, or a search result list that grows as the user consumes it.

The hook is useInfiniteQuery, and its result shape differs from useQuery in three important ways. The data object contains a pages array and a pageParams array instead of a single page. The fetchNextPage function loads the next page, and the hasNextPage boolean indicates whether another page exists. The getNextPageParam option is the function that determines both whether there is a next page and what parameter to use for it . The initialPageParam option is required in v5 and replaces the old default-parameter pattern in the query function .

Key point: getNextPageParam receives the last page, the array of all pages, and the last page parameter. It returns the parameter for the next page, or undefined (or null) if there is no next page. The hasNextPage boolean is true only when the function returns a value other than undefined or null. The isFetchingNextPage boolean distinguishes a “load more” fetch from a background refetch, and the fetchNextPage call should be guarded with !isFetching to prevent overlapping fetches that can overwrite each other .


Why infinite queries require a different data shape

The page-array problem.

A regular query returns a single data value. An infinite query returns an object with pages and pageParams. The pages array contains the response from each fetch, in order, and the pageParams array contains the parameter that produced each page . A component that renders the list must iterate over data.pages and then over the items within each page. The alternative is to flatten the pages into a single array with data.pages.flatMap(page => page.items), which is often simpler for rendering but loses the page boundaries that some UI features need.

The next-page-param problem.

The getNextPageParam function is the only way the hook knows whether another page exists and what parameter to use. It receives the last page and the full array of pages, and it returns the next parameter. If the API returns a nextCursor field, the function returns lastPage.nextCursor. If the API returns a hasMore boolean and a page number, the function returns lastPage.hasMore ? allPages.length + 1 : undefined . A function that returns undefined permanently produces an infinite list with only one page; a function that returns a value that the API rejects produces an error on the next fetch.

The overlap problem.

There is only one ongoing fetch per infinite query cache entry. Calling fetchNextPage while a fetch is already running can overwrite the result of the first fetch, and the data is lost or duplicated. The isFetching flag must be checked before calling fetchNextPage, and the button or the scroll handler must be disabled while it is true . The cancelRefetch: false option can be passed to fetchNextPage to allow simultaneous fetches, but this is rarely needed and should be used with care.


a. The basic load-more pattern

The load-more pattern uses a button that calls fetchNextPage. The button is disabled when there is no next page or when a fetch is already running.

import { useInfiniteQuery } from '@tanstack/react-query'

interface ProjectsResponse {
  projects: { id: number; name: string }[]
  nextCursor: number | undefined
}

function Projects() {
  const {
    data,
    error,
    fetchNextPage,
    hasNextPage,
    isFetching,
    isFetchingNextPage,
    status,
  } = useInfiniteQuery({
    queryKey: ['projects'],
    queryFn: ({ pageParam }) => fetchProjects(pageParam),
    initialPageParam: 0,
    getNextPageParam: (lastPage) => lastPage.nextCursor,
  })

  if (status === 'pending') return <p>Loading...</p>
  if (status === 'error') return <p>Error: {error.message}</p>

  return (
    <div>
      {data.pages.map((group, i) => (
        <div key={i}>
          {group.projects.map((project) => (
            <p key={project.id}>{project.name}</p>
          ))}
        </div>
      ))}

      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetching}
      >
        {isFetchingNextPage ? 'Loading more...' : hasNextPage ? 'Load More' : 'Nothing more to load'}
      </button>

      {isFetching && !isFetchingNextPage && <p>Fetching...</p>}
    </div>
  )
}

The data.pages.map iterates over the pages, and the inner group.projects.map iterates over the items in each page . The fetchNextPage() call is wrapped in an arrow function to avoid passing the click event as a parameter, which would override the pageParam returned by getNextPageParam . The button is disabled when !hasNextPage or isFetching is true, and the label changes based on the state.


b. The infinite scroll pattern with IntersectionObserver

The infinite scroll pattern replaces the button with a sentinel element at the bottom of the list. An IntersectionObserver watches the sentinel, and when it enters the viewport, it calls fetchNextPage. The observer must be created once and disconnected on cleanup.

import { useInfiniteQuery } from '@tanstack/react-query'
import { useEffect, useRef } from 'react'

function Feed() {
  const observerTarget = useRef<HTMLDivElement>(null)

  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isFetching,
    status,
    error,
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: ({ pageParam }) => fetchPosts(pageParam),
    initialPageParam: 0,
    getNextPageParam: (lastPage) => lastPage.nextCursor,
  })

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
          fetchNextPage()
        }
      },
      { threshold: 0.1, rootMargin: '200px' }
    )

    const target = observerTarget.current
    if (target) observer.observe(target)

    return () => {
      if (target) observer.unobserve(target)
    }
  }, [fetchNextPage, hasNextPage, isFetchingNextPage])

  if (status === 'pending') return <p>Loading...</p>
  if (status === 'error') return <p>Error: {error.message}</p>

  const allPosts = data.pages.flatMap((page) => page.posts)

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

      <div ref={observerTarget}>
        {isFetchingNextPage && <p>Loading more...</p>}
      </div>
    </div>
  )
}

The rootMargin: '200px' option triggers the fetch before the sentinel is visible, which reduces the perceived latency. The !isFetchingNextPage guard prevents duplicate fetches when the sentinel is already intersecting . The data.pages.flatMap flattens the pages into a single array for rendering, which is simpler than nested loops when the page boundaries are not visually significant.

The observer callback should also check !isFetching in some implementations, because a background refetch can run simultaneously with the next-page fetch. The isFetchingNextPage flag is specific to the next-page fetch, while isFetching covers all fetches, including background refetches. The safest guard is hasNextPage && !isFetching .


c. Cursor-based pagination and the maxPages option

The cursor-based pattern uses the API’s nextCursor field as the page parameter. The getNextPageParam function returns lastPage.nextCursor, and the query function sends it to the API.

const fetchProjects = async ({ pageParam }: { pageParam: number }) => {
  const res = await fetch(`/api/projects?cursor=${pageParam}`)
  return res.json()
}

useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  initialPageParam: 0,
  getNextPageParam: (lastPage) => lastPage.nextCursor,
})

The cursor is opaque to the client. It can be an ID, a timestamp, or an encoded string, and the client only needs to pass it back to the API . The initialPageParam: 0 is the starting cursor, and the API returns the first page and the next cursor. The getNextPageParam returns the next cursor, and the process repeats until the API returns undefined or null.

The maxPages option limits the number of pages stored in the cache. When the user scrolls forward past the limit, the oldest pages are dropped, and the memory usage stays bounded. The option requires getPreviousPageParam so that the dropped pages can be re-fetched when the user scrolls back .

useInfiniteQuery({
  queryKey: ['posts'],
  queryFn: ({ pageParam }) => fetchPosts(pageParam),
  initialPageParam: 0,
  getNextPageParam: (lastPage) => lastPage.nextCursor,
  getPreviousPageParam: (firstPage) => firstPage.prevCursor,
  maxPages: 3,
})

Without maxPages, an infinite query accumulates every page in memory, and a refetch fetches every page sequentially, which becomes slow for long lists . The maxPages option is the memory bound that keeps the list efficient, and the getPreviousPageParam function is the mechanism that allows the dropped pages to be restored.


Complete Example Session

// ============================================
// PART 1: THE API RESPONSE SHAPE
// ============================================
interface Post {
  id: number
  title: string
}

interface PostsPage {
  posts: Post[]
  nextCursor: number | undefined
}

// ============================================
// PART 2: THE FETCH FUNCTION
// ============================================
async function fetchPosts(cursor: number): Promise<PostsPage> {
  const res = await fetch(`/api/posts?cursor=${cursor}`)
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
  return res.json()
}

// ============================================
// PART 3: THE INFINITE QUERY
// ============================================
import { useInfiniteQuery } from '@tanstack/react-query'

function PostFeed() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetching,
    isFetchingNextPage,
    status,
    error,
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: ({ pageParam }) => fetchPosts(pageParam),
    initialPageParam: 0,
    getNextPageParam: (lastPage) => lastPage.nextCursor,
  })

// ============================================
// PART 4: THE PENDING AND ERROR STATES
// ============================================
  if (status === 'pending') return <p>Loading...</p>
  if (status === 'error') return <p>Error: {error.message}</p>

// ============================================
// PART 5: THE FLATTENED LIST
// ============================================
  const allPosts = data.pages.flatMap((page) => page.posts)

// ============================================
// PART 6: THE RENDER
// ============================================
  return (
    <div>
      {allPosts.map((post) => (
        <article key={post.id}>{post.title}</article>
      ))}

// ============================================
// PART 7: THE LOAD-MORE BUTTON
// ============================================
      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetching}
      >
        {isFetchingNextPage ? 'Loading...' : hasNextPage ? 'Load More' : 'Done'}
      </button>

// ============================================
// PART 8: THE BACKGROUND FETCH INDICATOR
// ============================================
      {isFetching && !isFetchingNextPage && <p>Refreshing...</p>}
    </div>
  )
}

The eight parts demonstrate the complete infinite query: the response shape, the fetch function, the hook configuration, the pending and error states, the flattened list, the render, the load-more button, and the background indicator. The critical configuration is getNextPageParam, which returns the nextCursor from the last page and produces hasNextPage automatically.


Quick Reference

useInfiniteQuery Options

OptionRequiredPurpose
queryKeyYesCache key
queryFnYesReceives pageParam
initialPageParamYes (v5)First page parameter
getNextPageParamYes (v5)Returns next param or undefined
getPreviousPageParamNoFor bi-directional lists
maxPagesNoLimit stored pages

Result Properties

PropertyPurpose
data.pagesArray of fetched pages
data.pageParamsArray of parameters
fetchNextPageLoad the next page
hasNextPageTrue if next param exists
isFetchingNextPageTrue during next-page fetch
isFetchingTrue during any fetch

Pagination Patterns

PatternTriggerUse Case
Load moreButtonExplicit user action
Infinite scrollIntersectionObserverFeed, timeline
Bi-directionalfetchPreviousPageChat, comment thread

Best Practices

✅ Do This:

// Guard fetchNextPage with isFetching
disabled={!hasNextPage || isFetching}                          # ✅

// Wrap fetchNextPage in an arrow function
onClick={() => fetchNextPage()}                                # ✅

// Return undefined when there is no next page
getNextPageParam: (lastPage) => lastPage.nextCursor             # ✅

// Use maxPages for long lists
maxPages: 3                                                    # ✅

// Check isFetchingNextPage for the load-more indicator
{isFetchingNextPage && <Spinner />}                            # ✅

// Flatten pages for simple rendering
data.pages.flatMap((page) => page.items)                       # ✅

❌ Don’t Do This:

// Pass the event to fetchNextPage
onClick={fetchNextPage}    // Event overrides pageParam       # ❌

// Call fetchNextPage without checking isFetching
onClick={() => fetchNextPage()}    // Overlapping fetches     # ❌

// Return a value from getNextPageParam when there is no next page
return lastPage.nextCursor    // undefined is correct          # ❌

// Forget initialPageParam in v5
useInfiniteQuery({ queryKey, queryFn, getNextPageParam })      # ❌

// Ignore maxPages for a long feed
// Memory grows with every page                                # ❌

Common Pitfalls

PitfallWhy It HappensFix
Duplicate fetchesfetchNextPage called without isFetching guardCheck !isFetching
hasNextPage always truegetNextPageParam returns a value foreverReturn undefined when done
hasNextPage always falsegetNextPageParam returns undefinedReturn the next cursor
Event passed as pageParamonClick={fetchNextPage}Use () => fetchNextPage()
Memory bloatNo maxPagesAdd maxPages with getPreviousPageParam
v5 errorMissing initialPageParamAdd it

Real-World Examples

1. Basic infinite query

useInfiniteQuery({ queryKey: ['posts'], queryFn: fetchPosts, initialPageParam: 0, getNextPageParam: (last) => last.nextCursor })

2. Load-more button

<button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetching}>Load More</button>

3. Flatten pages

const all = data.pages.flatMap((page) => page.items)

4. Infinite scroll sentinel

<div ref={observerTarget} />

5. IntersectionObserver callback

if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage()

6. maxPages

maxPages: 3, getPreviousPageParam: (first) => first.prevCursor

7. Loading-more indicator

{isFetchingNextPage && <p>Loading more...</p>}

8. Background indicator

{isFetching && !isFetchingNextPage && <p>Refreshing...</p>}

9. Cursor from API

getNextPageParam: (lastPage) => lastPage.nextCursor

10. No-more-pages state

{!hasNextPage && <p>End of list</p>}

Visual

Infinite Query Data Shape

┌──────────────────────────────────────────────────────────────┐
│  data.pages IS AN ARRAY OF PAGES                             │
│                                                              │
│  data                                                        │
│  ├── pages: [                                                │
│  │     { posts: [P1, P2], nextCursor: 10 },                  │
│  │     { posts: [P3, P4], nextCursor: 20 },                  │
│  │     { posts: [P5, P6], nextCursor: undefined }            │
│  │   ]                                                       │
│  └── pageParams: [0, 10, 20]                                 │
│                                                              │
│  The component renders data.pages.flatMap(p => p.posts).     │
└──────────────────────────────────────────────────────────────┘

Load-More Flow

┌──────────────────────────────────────────────────────────────┐
│  getNextPageParam DETERMINES hasNextPage                     │
│                                                              │
│  Page 1 loads → nextCursor: 10                               │
│       │                                                      │
│       ▼                                                      │
│  getNextPageParam returns 10 → hasNextPage: true             │
│       │                                                      │
│       ▼                                                      │
│  User clicks "Load More" → fetchNextPage()                   │
│       │                                                      │
│       ▼                                                      │
│  Page 2 loads → nextCursor: 20                               │
│       │                                                      │
│       ▼                                                      │
│  Page 3 loads → nextCursor: undefined                        │
│       │                                                      │
│       ▼                                                      │
│  getNextPageParam returns undefined → hasNextPage: false     │
│       │                                                      │
│       ▼                                                      │
│  Button disabled, "Done" label                               │
└──────────────────────────────────────────────────────────────┘

Infinite Scroll with IntersectionObserver

┌──────────────────────────────────────────────────────────────┐
│  THE SENTINEL TRIGGERS THE NEXT PAGE                         │
│                                                              │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ Post 1                                                 │  │
│  │ Post 2                                                 │  │
│  │ Post 3                                                 │  │
│  │ ...                                                    │  │
│  │ Post N                                                 │  │
│  │                                                        │  │
│  │ ┌────────────────────────────────────────────────────┐ │  │
│  │ │ <div ref={observerTarget} />  ← sentinel           │ │  │
│  │ └────────────────────────────────────────────────────┘ │  │
│  └────────────────────────────────────────────────────────┘  │
│                           │                                  │
│                           ▼                                  │
│  When the sentinel enters the viewport:                      │
│  → observer callback fires                                   │
│  → fetchNextPage() is called                                 │
│  → new page is appended                                      │
│  → sentinel moves down                                       │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
HookuseInfiniteQuery
Data shape{ pages, pageParams }
Required (v5)initialPageParam, getNextPageParam
Next-page functionfetchNextPage
Next-page flaghasNextPage
Loading flagisFetchingNextPage
Overlap guard!isFetching
Scroll triggerIntersectionObserver
Memory boundmaxPages (with getPreviousPageParam)
Flatteningdata.pages.flatMap(page => page.items)

Key takeaways:

  • useInfiniteQuery accumulates pages in a single cache entry. The data object contains a pages array and a pageParams array, and the component iterates over the pages or flattens them . The pages are appended, not replaced, which is the difference between infinite queries and the pagination pattern from the previous chapter.
  • getNextPageParam determines whether there is a next page. It receives the last page and the full array of pages, and it returns the parameter for the next fetch. A return value of undefined or null sets hasNextPage to false and disables the load-more action .
  • initialPageParam is required in v5. The old pattern of defaulting the pageParam in the query function signature was removed because it stored undefined in the cache, which is not serializable. The explicit initialPageParam provides the first parameter .
  • The load-more call must be guarded with !isFetching. There is only one ongoing fetch per infinite query cache entry, and calling fetchNextPage while a fetch is running can overwrite the result . The button or the scroll handler should check the flag before calling the function.
  • The IntersectionObserver is the standard scroll trigger. It watches a sentinel element at the bottom of the list, and the callback calls fetchNextPage when the sentinel enters the viewport. The rootMargin option triggers the fetch before the sentinel is visible, and the !isFetchingNextPage guard prevents duplicate fetches .
  • maxPages bounds the memory usage. Without it, every page is stored in the cache and every refetch fetches all of them sequentially. The maxPages option drops the oldest pages, and getPreviousPageParam allows them to be re-fetched when the user scrolls back .

Remember: Infinite queries are for lists that grow. The useInfiniteQuery hook accumulates pages in a single cache entry, the getNextPageParam function determines whether there is a next page, and the fetchNextPage function loads it. The load-more button and the IntersectionObserver are the two triggers, and both must be guarded with the fetching flag to prevent overlapping requests. The maxPages option bounds the memory, and the getPreviousPageParam function allows the dropped pages to be restored. The result is a feed that loads seamlessly as the user consumes it, without the flicker of pagination and without the memory growth of an unbounded list.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!