skip to content

Pagination & Infinite Queries

useInfiniteQuery keeps pages and pageParams together and asks getNextPageParam where to go next, which is why cursor and offset strategies feel so different. Interviewers pair it with UX concerns like keeping the list from flickering between pages.

on this pageshow

explore

questions

4

In TanStack Query v5, how does useInfiniteQuery decide which page to fetch next, and how does it know there are no more pages?

level: juniorimportance: must knowfreq 62%

answer

  1. one cache entry, many pages
  2. a required starting param
  3. a function reads the last page
  4. undefined or null ends the list
  5. pages and pageParams stay index-aligned

basics

~10 s

useInfiniteQuery fetches the first page with initialPageParam, then asks getNextPageParam(lastPage, allPages, lastPageParam, allPageParams) for each next param. Returning undefined or null sets hasNextPage to false. Pages accumulate in data.pages and data.pageParams.

solid answer

~40 s

In v5, `useInfiniteQuery` keeps every loaded page under one query key as `{ pages, pageParams }`, two index-aligned arrays. The first request receives `pageParam = initialPageParam`, which v5 makes required. Each call to `fetchNextPage()` runs `getNextPageParam(lastPage, allPages, lastPageParam, allPageParams)`, passes the result to `queryFn` as `pageParam`, and appends the new page and its param. The same function drives `hasNextPage`: it is `false` only when the function returns `undefined` or `null`, so that is how you say the list has ended. With a cursor API you return `lastPage.nextCursor`; with an offset or page-number API you derive the next param from `lastPageParam` (for example `lastPageParam + 1`) and return `undefined` once a page comes back empty.

code

tsx · 41 lines
tsx
import { useInfiniteQuery } from '@tanstack/react-query'

type Page = { items: { id: string; title: string }[]; nextCursor?: string }

async function fetchPosts({ pageParam }: { pageParam: string }): Promise<Page> {
  const res = await fetch(`/api/posts?cursor=${encodeURIComponent(pageParam)}`)
  if (!res.ok) throw new Error('Failed to load posts')
  return res.json()
}

export function Posts() {
  const { data, status, fetchNextPage, hasNextPage, isFetchingNextPage } =
    useInfiniteQuery({
      queryKey: ['posts'],
      queryFn: fetchPosts,
      initialPageParam: '',
      // A missing nextCursor makes this return undefined, which ends the list.
      getNextPageParam: (lastPage) => lastPage.nextCursor,
    })

  if (status === 'pending') return <p>Loading…</p>
  if (status === 'error') return <p>Could not load posts.</p>

  return (
    <>
      <ul>
        {data.pages
          .flatMap((page) => page.items)
          .map((post) => (
            <li key={post.id}>{post.title}</li>
          ))}
      </ul>
      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage ? 'Loading more…' : hasNextPage ? 'Load more' : 'No more posts'}
      </button>
    </>
  )
}

go deeper

for a junior

Recall the core names: initialPageParam, getNextPageParam, fetchNextPage, hasNextPage and data.pages. Say plainly that returning undefined from getNextPageParam is how the list ends.

for a middle

Explain the four arguments getNextPageParam receives and why pages and pageParams are index-aligned. Show a cursor version and a lastPageParam plus one version for an API without cursors.

for a senior

Point out that 0 and false are valid params and only null or undefined stop the list, and that a manual cache write on an infinite query must keep both arrays in step.

for a principal

Frame when an infinite list is the right experience at all versus numbered pages, and which API guarantees (stable cursors, a clear last page) keep the client logic this simple.

## One cache entry holding many pages `useInfiniteQuery` is the TanStack Query v5 hook for "load more" buttons and infinite scroll. A paginated `useQuery` gives every page its own query key. An infinite query works differently: it keeps **every loaded page under a single query key**, and its `data` is an object with two arrays: - **`data.pages`**: what your `queryFn` returned for each page, in display order. - **`data.pageParams`**: the **page param** each of those pages was fetched with. The two arrays are **index-aligned**, so `pageParams[i]` is the param that produced `pages[i]`. The library relies on that alignment when it adds pages or refetches the list. That is why the docs insist that a manual `setQueryData` on an infinite query must keep both arrays in step. ## How the next page param is chosen A **page param** is whatever your API needs to locate a page, such as a cursor string, an offset or a page number. TanStack Query never invents one. You supply it through options: 1. **`initialPageParam`** (required in v5) is passed to `queryFn` as `pageParam` whenever the query has no pages yet, which includes the very first load. 2. When you call **`fetchNextPage()`**, the library calls **`getNextPageParam(lastPage, allPages, lastPageParam, allPageParams)`** with the current data and passes its return value to `queryFn` as `pageParam`. 3. The new page is **appended** to `pages`, and the param that produced it is appended to `pageParams`. 4. For a list that also grows upwards, **`getPreviousPageParam(firstPage, allPages, firstPageParam, allPageParams)`** does the same job for `fetchPreviousPage()`, and the result is **prepended**. Because the next param is computed from data the cache already holds, `fetchNextPage` takes no page number. v4 let you override the param by passing one; v5 removed that option, and `getNextPageParam` became a required option at the same time. ## Ending the list: undefined or null `getNextPageParam` does two jobs. Its return value is also what **`hasNextPage`** reports. The flag is `true` when the function returns anything other than `null` or `undefined`, and `false` when it returns one of those two. So "there are no more pages" is expressed by returning `undefined` (or `null`). It is not a flag the server has to send, although your function may well read one from the response to decide. Two consequences are easy to miss: - `0`, an empty string and `false` are **valid params**, not stop signals. A page-number scheme that starts at `0` works, and a function that returns `false` to mean "done" leaves `hasNextPage` true. - If `fetchNextPage()` is called while the function returns `undefined` or `null`, the library does not call `queryFn` for a new page and the loaded pages stay as they are. `hasPreviousPage` works the same way through `getPreviousPageParam`, and is `false` whenever that option is not defined. ## Cursor and offset params Both strategies fit the same function. They just read different inputs: | API style | `initialPageParam` | `getNextPageParam` returns | how it stops | |---|---|---|---| | Cursor | the starting cursor your API accepts | `lastPage.nextCursor` | the response has no cursor, so the function returns `undefined` | | Offset or page number | `0` (or `1`) | `lastPageParam + 1` | the last page came back empty, so you return `undefined` | The cursor version trusts the server to say where to continue. The offset version derives the next param from the previous **param**, the third argument, which is the documented pattern when an API returns no cursor. The difference users notice appears when rows are inserted while they scroll. An offset window shifts, so the next page can repeat items already on screen. A cursor anchored to the last item seen does not shift. ## Rendering the pages - Flatten during render with `data.pages.flatMap((page) => page.items)` and map the result once. - Show a "Loading more" indicator from `isFetchingNextPage`. For a background refresh of the whole list, use `isFetching && !isFetchingNextPage`, as the docs do, because `isFetching` is also true while the next page loads. - Disable the trigger when `hasNextPage` is false, so the UI stops offering pages that do not exist. - On the first load there are no pages yet: `status` is `'pending'` and `data` is `undefined`, exactly as with `useQuery`. ## What changed from v4 In v4 the first `pageParam` was `undefined`, and code defaulted it in the query function signature (`({ pageParam = 0 })`). That stored `undefined` in the cache, and `undefined` is not serializable. v5 made `initialPageParam` explicit and required, made `getNextPageParam` required, removed passing a param to `fetchNextPage`, and started treating `null` as "no more pages" alongside `undefined`. Code written against v4 that relies on any of those four behaviours has to change when it moves to v5.

  • Why does TanStack Query v5 require initialPageParam instead of letting the query function default it?
    In v4 the first `pageParam` was `undefined`, and people defaulted it in the function signature. The cache then stored `undefined` in `pageParams`, and `undefined` is not serializable. v5 makes the starting param an explicit, required option, stores it in `pageParams[0]` like every other param, and uses it again whenever the query has no pages.
  • How would you let the same list also load older items above the ones on screen?
    Add `getPreviousPageParam(firstPage, allPages, firstPageParam, allPageParams)` and call `fetchPreviousPage()`. The library prepends the new page to `data.pages` and its param to `data.pageParams`, and exposes `hasPreviousPage` and `isFetchingPreviousPage`. Return `undefined` or `null` from `getPreviousPageParam` once the first loaded page is the true start of the list.
  • Should you copy the flattened pages into component state for rendering?
    No. Derive the flat list during render from `data.pages`, and memoize it if the list is large. A copy in `useState` is a second source of truth: it misses every appended page and every background refetch unless you sync it by hand, while deriving from the cache always shows what the query holds.

saying these in an interview costs you the question

  • In v5 the first pageParam is undefined, so default it in the queryFn signature.
  • hasNextPage comes from a hasMore field the server must send.
  • Returning 0 or false from getNextPageParam ends the list.
  • Each loaded page is cached under its own query key.
  • fetchNextPage takes the next page number as its argument.
open as a page

With TanStack Query v5, a table keyed ['orders', page] flashes a loading state on every page change; how do you keep the previous page visible?

level: middleimportance: must knowfreq 60%

basics

~20 s

Each page number is a new query key, so its entry starts pending with no data. Setting placeholderData: keepPreviousData shows the previous page's data while the next one loads, with isPlaceholderData true until the real page arrives.

open as a page

When a stale TanStack Query v5 infinite query holding ten pages refetches, what requests does it send, and how does maxPages change that?

level: middleimportance: should knowfreq 42%

basics

~20 s

It refetches all ten pages one after another, starting from the first stored param and computing each next param from the freshly fetched page. maxPages caps how many pages are kept, so a refetch requests at most that many.

open as a page

An infinite feed built with TanStack Query v5's useInfiniteQuery calls fetchNextPage from an IntersectionObserver sentinel, and the network tab shows repeated and cancelled page requests; why, and how do you fix it?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Repeated sentinel callbacks call fetchNextPage while a fetch is running, and its default cancelRefetch: true cancels that fetch and starts another for the same page. Guard the call with hasNextPage && !isFetching, or pass { cancelRefetch: false }.

open as a page