In TanStack Query v5, how does useInfiniteQuery decide which page to fetch next, and how does it know there are no more pages?
answer
- one cache entry, many pages
- a required starting param
- a function reads the last page
- undefined or null ends the list
- pages and pageParams stay index-aligned
basics
~10 suseInfiniteQuery 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 sIn 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 linesimport { 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
Recall the core names: initialPageParam, getNextPageParam, fetchNextPage, hasNextPage and data.pages. Say plainly that returning undefined from getNextPageParam is how the list ends.
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.
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.
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.