Implement a load-more list that avoids duplicate fetches, bounds memory, and detects the final page correctly.
Infinite data is not a plain array
Infinite-query cache stores both data.pages and data.pageParams. initialPageParam is the required starting point; getNextPageParam returns the next pageParam and null or undefined means the list has ended. Manual cache updates must preserve both arrays and their correspondence.
Let the server cursor own pagination truth
Take the cursor from the previous server response rather than guessing it on the client. The queryKey describes list filters while pageParam describes the current page position; putting every cursor into the key splits pages into unrelated queries.
import { infiniteQueryOptions } from '@tanstack/react-query'
export const issuesInfiniteOptions = infiniteQueryOptions({
queryKey: ['issues', 'infinite'] as const,
initialPageParam: null as string | null,
queryFn: async ({ pageParam, signal }) => {
const search = pageParam ? `?cursor=${encodeURIComponent(pageParam)}` : ''
const response = await fetch(`/api/issues${search}`, { signal })
if (!response.ok) throw new Error('Unable to load issues')
return response.json() as Promise<{ items: Issue[]; nextCursor: string | null }>
},
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
maxPages: 5,
})Separate initial, next-page, and background states
A load-more action should check both hasNextPage and isFetchingNextPage to avoid duplicate requests. Distinguish initial pending, next-page loading, and background refetch states. maxPages bounds memory and sequential refetch cost; bidirectional lists must also define getPreviousPageParam.
Turn this lesson into a verifiable skill
Each load uses the cursor returned by the previous page, hasNextPage becomes false at the end, and pages always matches pageParams length.
Build a five-page-bounded issue timeline with load-more, then verify a filter change starts from the first page of a separate cache entry.
Record three request cursors and simulate the final page, a retry, and rapid double-click; cursors do not repeat, the failed page retries, and duplicate next-page requests do not overlap.
- Putting pageParam in the queryKey and splitting each page into its own query.
- Editing pages without preserving pageParams and corrupting InfiniteData.