TanStack Query 10 ⚛️ Pagination — Managing paginated query requests
Paginated data is a sequence of discrete pages, each fetched with its own query key. The naive implementation—including the page number in the key and rendering whatever useQuery returns—works, but it produces a UI that flickers between success and pending states on every page change. Each page is a new query, and a new query has no data, so the component unmounts the list and shows a spinner. The solution is placeholderData, which keeps the previous page’s data visible while the next page loads, eliminating the flicker and providing a smooth transition .
This chapter covers the paginated query pattern: the query key that includes the page, the placeholderData: keepPreviousData option that prevents the loading flash, the isPlaceholderData flag that indicates when the displayed data is from a previous page, and the prefetching pattern that warms the next page before the user requests it. It builds on the query key hierarchy from earlier chapters and the stale-while-revalidate behavior that governs all queries.
Key point: The page number must be part of the query key. placeholderData: keepPreviousData keeps the previous page’s data in the component while the new page’s query is pending, so the UI never returns to a loading state. The isPlaceholderData flag is true while the displayed data is from the previous page and false once the new page’s data arrives. Disable the “Next” button while isPlaceholderData is true to prevent the user from requesting a page that has not loaded yet .
Why pagination requires placeholder data
The query-key problem.
Each page is a separate cache entry. ['projects', 0], ['projects', 1], and ['projects', 2] are three distinct queries. When the user clicks “Next,” the component’s useQuery call changes its key from ['projects', 0] to ['projects', 1]. The query for page 1 has no cached data yet, so useQuery returns isPending: true and data: undefined. The component’s render logic sees isPending and shows a spinner, even though the user was just looking at page 0. The transition is jarring and unnecessary .
The placeholder-data solution.
placeholderData provides a temporary value for the query while the real data loads. When set to keepPreviousData, it returns the data from the previous query key—the page the user was just viewing—until the new page’s data arrives. The component continues to render the previous page’s list, and when the new data resolves, it swaps in seamlessly. The isPending state never becomes true for a page change, because data is never undefined .
The isPlaceholderData flag.
The flag distinguishes between data that belongs to the current key and data that is a placeholder from the previous key. It is true while the placeholder is being shown and false once the real data arrives. This flag is used to disable the “Next” button while the new page is loading, preventing the user from clicking through to a page that has not yet been fetched. It is also used to apply a visual indication—typically reduced opacity—to signal that the displayed data is transitional .
a. The basic paginated query
The query key includes the page number, and the query function uses it to fetch the correct page. The placeholderData option keeps the previous page visible during the transition.
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { useState } from 'react'
function ProjectList() {
const [page, setPage] = useState(0)
const { data, isPlaceholderData, isFetching } = useQuery({
queryKey: ['projects', page], // Page is part of the key
queryFn: () => fetchProjects(page), // Fetches the correct page
placeholderData: keepPreviousData, // Keeps previous page visible
staleTime: 5000, // Optional: data is fresh for 5 seconds
})
return (
<div>
{/* Render the list; data is never undefined on page change */}
{data?.projects.map((project) => (
<p key={project.id}>{project.name}</p>
))}
{/* Show a subtle indicator during the background fetch */}
{isFetching && <span>Loading...</span>}
<button
onClick={() => setPage((old) => Math.max(old - 1, 0))}
disabled={page === 0}
>
Previous
</button>
<span>Page {page + 1}</span>
<button
onClick={() => setPage((old) => old + 1)}
disabled={isPlaceholderData || !data?.hasMore} // Disable while placeholder
>
Next
</button>
</div>
)
}
The isPlaceholderData check on the “Next” button is essential. Without it, a user who clicks “Next” twice in quick succession could request page 2 while page 1 is still loading, and the query for page 2 would be created before page 1’s data has arrived. The check ensures that each page is confirmed before the next one is requested .
b. Prefetching the next page
Prefetching warms the next page’s cache before the user clicks “Next.” The prefetch runs in a useEffect that watches the current page’s data, and it calls queryClient.prefetchQuery for the next page. When the user clicks “Next,” the data is already in the cache, and the transition is instant.
import { useEffect } from 'react'
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
function ProjectList() {
const queryClient = useQueryClient()
const [page, setPage] = useState(0)
const { data, isPlaceholderData } = useQuery({
queryKey: ['projects', page],
queryFn: () => fetchProjects(page),
placeholderData: keepPreviousData,
staleTime: 5000,
})
// Prefetch the next page when the current page is loaded
useEffect(() => {
if (!isPlaceholderData && data?.hasMore) {
queryClient.prefetchQuery({
queryKey: ['projects', page + 1],
queryFn: () => fetchProjects(page + 1),
staleTime: 5000,
})
}
}, [data, isPlaceholderData, page, queryClient])
return (
// ... same render as before
)
}
The prefetch runs only when the current page’s data is real (!isPlaceholderData) and there is a next page (data?.hasMore). The prefetched data is stored in the cache under the next page’s key, so when the user clicks “Next,” the query finds the data immediately and does not show a pending state. The staleTime on the prefetch should match the staleTime on the query to ensure consistent freshness behavior .
The combination of keepPreviousData and prefetching produces the best pagination experience: no loading states on page changes, and instant transitions because the next page is already cached. The user perceives the application as fast and responsive, even though the data is being fetched from the network .
c. Page-based versus offset-based pagination
The query key can include either a page number or an offset. The choice depends on the API’s design.
// Page-based: the API takes a page number
const { data } = useQuery({
queryKey: ['projects', page],
queryFn: () => fetch(`/api/projects?page=${page}&limit=10`).then((r) => r.json()),
placeholderData: keepPreviousData,
})
// Offset-based: the API takes an offset
const ITEMS_PER_PAGE = 10
const { data } = useQuery({
queryKey: ['projects', { offset: page * ITEMS_PER_PAGE }],
queryFn: () =>
fetch(`/api/projects?offset=${page * ITEMS_PER_PAGE}&limit=${ITEMS_PER_PAGE}`).then(
(r) => r.json()
),
placeholderData: keepPreviousData,
})
The page-based form is simpler when the API supports it. The offset-based form is necessary when the API requires the offset, and the query key must include the offset value so that each page has a distinct cache entry . In both cases, the placeholderData option behaves identically: it returns the previous key’s data while the new key’s query is pending.
For cursor-based pagination, where the API returns a cursor for the next page rather than a page number, useInfiniteQuery is usually the correct hook. Pagination with useQuery and placeholderData is for discrete page navigation, where the user clicks “Next” and “Previous” and expects to see one page at a time .
Complete Example Session
// ============================================
// PART 1: THE QUERY KEY FACTORY
// ============================================
// The page is part of the key hierarchy
const projectKeys = {
all: ['projects'] as const,
lists: () => [...projectKeys.all, 'list'] as const,
list: (page: number) => [...projectKeys.lists(), { page }] as const,
}
// ============================================
// PART 2: THE PAGINATED QUERY
// ============================================
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
function ProjectList() {
const queryClient = useQueryClient()
const [page, setPage] = useState(0)
const { data, isPlaceholderData, isFetching, isError, error } = useQuery({
queryKey: projectKeys.list(page),
queryFn: () => fetchProjects(page),
placeholderData: keepPreviousData,
staleTime: 5000,
})
// ============================================
// PART 3: THE PREFETCH EFFECT
// ============================================
useEffect(() => {
if (!isPlaceholderData && data?.hasMore) {
queryClient.prefetchQuery({
queryKey: projectKeys.list(page + 1),
queryFn: () => fetchProjects(page + 1),
staleTime: 5000,
})
}
}, [data, isPlaceholderData, page, queryClient])
// ============================================
// PART 4: THE RENDER WITH PLACEHOLDER INDICATION
// ============================================
if (isError) return <p>Error: {error.message}</p>
return (
<div style={{ opacity: isPlaceholderData ? 0.5 : 1 }}>
{data?.projects.map((project) => (
<p key={project.id}>{project.name}</p>
))}
{isFetching && <span>Updating...</span>}
// ============================================
// PART 5: THE NAVIGATION BUTTONS
// ============================================
<button
onClick={() => setPage((old) => Math.max(old - 1, 0))}
disabled={page === 0}
>
Previous
</button>
<span>Page {page + 1}</span>
<button
onClick={() => setPage((old) => old + 1)}
disabled={isPlaceholderData || !data?.hasMore}
>
Next
</button>
</div>
)
}
// ============================================
// PART 6: THE FETCH FUNCTION
// ============================================
async function fetchProjects(page: number) {
const response = await fetch(`/api/projects?page=${page}&limit=10`)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json()
}
The six parts demonstrate the complete paginated query: the key factory that includes the page, the query with keepPreviousData, the prefetch effect that warms the next page, the render that indicates placeholder data with reduced opacity, the navigation buttons that disable “Next” while placeholder data is shown, and the fetch function that includes the page in the request. The critical distinction is Part 5: the “Next” button is disabled while isPlaceholderData is true, preventing the user from requesting a page that has not loaded yet.
Quick Reference
Pagination Options
| Option | Value | Purpose |
|---|---|---|
queryKey | ['entity', page] | Page is part of the key |
placeholderData | keepPreviousData | Keeps previous page visible |
staleTime | 5000 | Freshness window for page data |
isPlaceholderData | Boolean | True while showing previous page |
isFetching | Boolean | True during background fetch |
Navigation Button States
| Button | Disabled When | Reason |
|---|---|---|
| Previous | page === 0 | No previous page |
| Next | isPlaceholderData | Current page not yet loaded |
| Next | !data?.hasMore | No next page |
Pagination Patterns
| Pattern | Hook | Use Case |
|---|---|---|
| Page-based | useQuery + placeholderData | Discrete page navigation |
| Offset-based | useQuery + placeholderData | API requires offset |
| Cursor-based | useInfiniteQuery | Load more, infinite scroll |
Best Practices
✅ Do This:
// Include the page in the query key
queryKey: ['projects', page] # ✅
// Use keepPreviousData to prevent the loading flash
placeholderData: keepPreviousData # ✅
// Disable Next while placeholder data is shown
disabled={isPlaceholderData || !data?.hasMore} # ✅
// Prefetch the next page when the current page loads
queryClient.prefetchQuery({ queryKey: ['projects', page + 1], ... }) # ✅
// Apply a visual indication for placeholder data
style={{ opacity: isPlaceholderData ? 0.5 : 1 }} # ✅
// Use isFetching for a subtle background indicator
{isFetching && <span>Updating...</span>} # ✅
❌ Don’t Do This:
// Omit placeholderData
// The UI flashes a loading state on every page change # ❌
// Forget the page in the query key
// Every page shares one cache entry # ❌
// Allow Next while placeholder data is shown
// The user can request a page before the current one loads # ❌
// Prefetch without checking isPlaceholderData
// The prefetch runs for a page that is not yet confirmed # ❌
// Use isPending to show the loading state
// With keepPreviousData, isPending is never true on page change # ❌
// Use keepPreviousData with cursor-based pagination
// Use useInfiniteQuery for cursor-based pagination # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| UI flickers on page change | No placeholderData | placeholderData: keepPreviousData |
| Previous page’s data is not shown | Page not in query key | Include page in the key |
| Next button enabled while loading | isPlaceholderData not checked | Disable while isPlaceholderData |
| Prefetch runs for wrong page | page not in the effect dependency | Include page in the dependency array |
| Placeholder data never replaced | isPlaceholderData not transitioning | Verify the new data resolves successfully |
| Infinite scroll instead of pagination | Used useQuery for cursor pagination | Use useInfiniteQuery |
Real-World Examples
1. Basic paginated query
useQuery({ queryKey: ['projects', page], queryFn: () => fetchProjects(page), placeholderData: keepPreviousData })
2. Disable Next while placeholder
disabled={isPlaceholderData || !data?.hasMore}
3. Prefetch next page
queryClient.prefetchQuery({ queryKey: ['projects', page + 1], queryFn: () => fetchProjects(page + 1) })
4. Visual placeholder indication
<div style={{ opacity: isPlaceholderData ? 0.5 : 1 }}>
5. Background fetch indicator
{isFetching && <span>Updating...</span>}
6. Offset-based key
queryKey: ['projects', { offset: page * 10 }]
7. Key factory for pagination
const keys = { list: (page: number) => ['projects', 'list', { page }] as const }
8. Previous button disabled
disabled={page === 0}
9. Next button disabled
disabled={isPlaceholderData || !data?.hasMore}
10. Prefetch with staleTime
queryClient.prefetchQuery({ queryKey: ['projects', page + 1], queryFn: ..., staleTime: 5000 })
Visual
Pagination Without keepPreviousData
┌──────────────────────────────────────────────────────────────┐
│ WITHOUT placeholderData, EVERY PAGE CHANGE SHOWS A SPINNER │
│ │
│ Page 0: data = [A, B, C] isPending = false │
│ │ │
│ │ User clicks Next │
│ ▼ │
│ Page 1: data = undefined isPending = true │
│ │ │
│ │ UI shows spinner; list disappears │
│ ▼ │
│ Page 1: data = [D, E, F] isPending = false │
│ │ │
│ │ UI shows new list │
│ │
│ The user sees: list → spinner → list │
└──────────────────────────────────────────────────────────────┘
Pagination With keepPreviousData
┌──────────────────────────────────────────────────────────────┐
│ WITH placeholderData, THE PREVIOUS PAGE STAYS VISIBLE │
│ │
│ Page 0: data = [A, B, C] isPlaceholderData = false │
│ │ │
│ │ User clicks Next │
│ ▼ │
│ Page 1: data = [A, B, C] isPlaceholderData = true │
│ │ (previous page data) │
│ │ UI still shows [A, B, C] with reduced opacity │
│ │ Background fetch runs │
│ ▼ │
│ Page 1: data = [D, E, F] isPlaceholderData = false │
│ │ (new page data) │
│ │ UI shows [D, E, F] │
│ │
│ The user sees: list → list → list (no spinner) │
└──────────────────────────────────────────────────────────────┘
Prefetching Flow
┌──────────────────────────────────────────────────────────────┐
│ PREFETCH WARMS THE NEXT PAGE BEFORE THE USER CLICKS │
│ │
│ Page 0 loads → data = [A, B, C] │
│ │ │
│ ▼ │
│ useEffect runs (isPlaceholderData = false, hasMore = true) │
│ │ │
│ ▼ │
│ prefetchQuery(['projects', 1]) │
│ │ │
│ ▼ │
│ Page 1 data cached (invisible to user) │
│ │ │
│ │ User clicks Next │
│ ▼ │
│ Page 1: data = [D, E, F] isPlaceholderData = false │
│ │ (already cached; instant transition) │
│ │ │
│ ▼ │
│ useEffect prefetches Page 2 │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Query key | Must include the page: ['entity', page] |
| Placeholder option | placeholderData: keepPreviousData |
| Placeholder flag | isPlaceholderData (true while showing previous page) |
| Next button disable | isPlaceholderData || !data?.hasMore |
| Prefetch | queryClient.prefetchQuery for page + 1 |
| Background indicator | isFetching |
| Page-based API | ?page=N&limit=M |
| Offset-based API | ?offset=N&limit=M |
| Cursor-based API | useInfiniteQuery (not useQuery) |
Key takeaways:
- Include the page in the query key. Each page is a separate cache entry. The key is the address, and the page number is part of the address. Without it, every page shares one cache entry and the data is always wrong .
placeholderData: keepPreviousDataprevents the loading flash. Without it, the component unmounts the list and shows a spinner on every page change. With it, the previous page’s data remains visible while the new page loads, and the transition is seamless .isPlaceholderDataindicates when the displayed data is transitional. It istruewhile the previous page’s data is shown andfalseonce the new page’s data arrives. Use it to disable the “Next” button and to apply a visual indication .- Disable “Next” while
isPlaceholderDataistrue. This prevents the user from requesting page 2 before page 1 has loaded. Without the check, rapid clicks can create queries for pages that have not been confirmed. - Prefetch the next page for instant transitions. A
useEffectthat watches the current page’s data can callqueryClient.prefetchQueryforpage + 1. When the user clicks “Next,” the data is already in the cache, and the transition is immediate . - Use
useInfiniteQueryfor cursor-based pagination. TheuseQuery+placeholderDatapattern is for discrete page navigation. Cursor-based pagination, where the API returns a cursor for the next page, is better served byuseInfiniteQuery, which manages the page array and the fetch-more behavior .
Remember: Pagination with TanStack Query is a matter of two decisions: the page must be in the query key, and the previous page’s data must be preserved during the transition. The query key determines which cache entry is used. The placeholderData option determines what is displayed while the new entry is being fetched. The isPlaceholderData flag determines whether the navigation controls should be disabled. Prefetching warms the next entry so the transition is instant. The result is a paginated list that never flashes a spinner and never leaves the user staring at an empty screen.
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!