TanStack Query 11 ⚛️ Infinite Queries — Implementing load-more and scroll paging
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
| Option | Required | Purpose |
|---|---|---|
queryKey | Yes | Cache key |
queryFn | Yes | Receives pageParam |
initialPageParam | Yes (v5) | First page parameter |
getNextPageParam | Yes (v5) | Returns next param or undefined |
getPreviousPageParam | No | For bi-directional lists |
maxPages | No | Limit stored pages |
Result Properties
| Property | Purpose |
|---|---|
data.pages | Array of fetched pages |
data.pageParams | Array of parameters |
fetchNextPage | Load the next page |
hasNextPage | True if next param exists |
isFetchingNextPage | True during next-page fetch |
isFetching | True during any fetch |
Pagination Patterns
| Pattern | Trigger | Use Case |
|---|---|---|
| Load more | Button | Explicit user action |
| Infinite scroll | IntersectionObserver | Feed, timeline |
| Bi-directional | fetchPreviousPage | Chat, 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Duplicate fetches | fetchNextPage called without isFetching guard | Check !isFetching |
hasNextPage always true | getNextPageParam returns a value forever | Return undefined when done |
hasNextPage always false | getNextPageParam returns undefined | Return the next cursor |
| Event passed as pageParam | onClick={fetchNextPage} | Use () => fetchNextPage() |
| Memory bloat | No maxPages | Add maxPages with getPreviousPageParam |
| v5 error | Missing initialPageParam | Add 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
| Item | Value |
|---|---|
| Hook | useInfiniteQuery |
| Data shape | { pages, pageParams } |
| Required (v5) | initialPageParam, getNextPageParam |
| Next-page function | fetchNextPage |
| Next-page flag | hasNextPage |
| Loading flag | isFetchingNextPage |
| Overlap guard | !isFetching |
| Scroll trigger | IntersectionObserver |
| Memory bound | maxPages (with getPreviousPageParam) |
| Flattening | data.pages.flatMap(page => page.items) |
Key takeaways:
useInfiniteQueryaccumulates pages in a single cache entry. Thedataobject contains apagesarray and apageParamsarray, 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.getNextPageParamdetermines 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 ofundefinedornullsetshasNextPagetofalseand disables the load-more action .initialPageParamis required in v5. The old pattern of defaulting thepageParamin the query function signature was removed because it storedundefinedin the cache, which is not serializable. The explicitinitialPageParamprovides the first parameter .- The load-more call must be guarded with
!isFetching. There is only one ongoing fetch per infinite query cache entry, and callingfetchNextPagewhile a fetch is running can overwrite the result . The button or the scroll handler should check the flag before calling the function. - The
IntersectionObserveris the standard scroll trigger. It watches a sentinel element at the bottom of the list, and the callback callsfetchNextPagewhen the sentinel enters the viewport. TherootMarginoption triggers the fetch before the sentinel is visible, and the!isFetchingNextPageguard prevents duplicate fetches . maxPagesbounds the memory usage. Without it, every page is stored in the cache and every refetch fetches all of them sequentially. ThemaxPagesoption drops the oldest pages, andgetPreviousPageParamallows 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.
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!