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?
answer
- every page is a new key
- a new key starts with no data
- borrow data from the last key
- an identity function the library exports
- a flag says the data is borrowed
basics
~20 sEach 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.
solid answer
~40 sBecause the key is `['orders', page]`, every page change creates a new cache entry. With nothing cached it starts in `pending` status with `data` undefined, so the component falls back to its skeleton. In v5 you pass `placeholderData: keepPreviousData`, imported from `@tanstack/react-query` and equivalent to `(previousData) => previousData`. While the new page fetches, the hook returns the previous key's data with `status: 'success'`, `isPlaceholderData: true` and `isFetching: true`, then swaps in the real page when it lands. Use `isPlaceholderData` to dim the table and disable Next, because the `hasMore` on screen still describes the old page. A page that is already cached is shown from the cache and needs no placeholder. This replaces v4's `keepPreviousData: true` option and its `isPreviousData` flag.
code
tsx · 60 linesimport { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
type OrdersPage = { rows: { id: string; total: number }[]; hasMore: boolean }
async function fetchOrders(page: number): Promise<OrdersPage> {
const res = await fetch(`/api/orders?page=${page}`)
if (!res.ok) throw new Error('Failed to load orders')
return res.json()
}
export function OrdersTable() {
const [page, setPage] = useState(0)
const queryClient = useQueryClient()
const { data, isPending, isError, isFetching, isPlaceholderData } = useQuery({
queryKey: ['orders', page],
queryFn: () => fetchOrders(page),
placeholderData: keepPreviousData,
})
// Prefetch the next page only once the current page is real data.
useEffect(() => {
if (!isPlaceholderData && data?.hasMore) {
void queryClient.prefetchQuery({
queryKey: ['orders', page + 1],
queryFn: () => fetchOrders(page + 1),
})
}
}, [data, isPlaceholderData, page, queryClient])
if (isPending) return <p>Loading orders…</p>
if (isError) return <p>Could not load orders.</p>
return (
<div style={{ opacity: isPlaceholderData ? 0.6 : 1 }}>
<h2>Page {page + 1}</h2>
<table>
<tbody>
{data.rows.map((row) => (
<tr key={row.id}>
<td>{row.id}</td>
<td>{row.total}</td>
</tr>
))}
</tbody>
</table>
<button onClick={() => setPage((p) => Math.max(p - 1, 0))} disabled={page === 0}>
Previous
</button>
<button
onClick={() => setPage((p) => p + 1)}
disabled={isPlaceholderData || !data.hasMore}
>
Next
</button>
{isFetching ? <span>Updating…</span> : null}
</div>
)
}go deeper
Remember that every page number is its own key, so a new page starts with no data. Name placeholderData: keepPreviousData as the fix and isPlaceholderData as the flag to check.
Explain why the flash happens, what status, isPending and isPlaceholderData report during the swap, and why the placeholder never lands in the cache.
Show the UI consequences: disable Next while isPlaceholderData is true because the visible hasMore is stale, and prefetch the following page so the placeholder is rarely needed.
Decide where showing old data during a key change helps users and where it misleads them, and set a team convention for when to borrow previous data and when to show a loading state.
## Why the table flashes In TanStack Query a **query key** is the identity of a cache entry. A numbered-page table typically puts the page number in the key, `['orders', page]`, so that each page is cached on its own. That is correct, but it has a side effect. When the user clicks Next, the component starts observing a key it has never seen. That new entry has **no data**, so the hook reports `status: 'pending'`, `isPending: true` and `data: undefined`. A component written as `if (isPending) return <Skeleton />` replaces the whole table with a skeleton for the length of the request, then draws the new rows. The docs describe exactly this: the UI jumps in and out of the `success` and `pending` states because each page is treated as a brand-new query. The flash costs more than a moment of grey boxes: - **Layout shift.** The skeleton rarely has the table's exact height, so everything below it jumps twice per click. - **Lost focus.** If the pagination controls render inside the branch that the skeleton replaces, the Next button unmounts, and a keyboard or screen-reader user loses their place on every page change. - **Lost context.** The user cannot compare the page they are leaving with the one arriving, which matters in tables of orders, logs or search results. ## The fix: placeholderData with keepPreviousData `placeholderData` lets a query behave as if it had data while the real data is fetched. The data is **never written to the cache**; it only appears in the hook's result. When `placeholderData` is a function, v5 calls it with the **previous data**, meaning the data of the last query this hook observed that had data (the previous page), plus that query itself. So an identity function is all you need: - `placeholderData: keepPreviousData`, where `keepPreviousData` is a helper exported from `@tanstack/react-query`, or - `placeholderData: (previousData) => previousData`, which does the same. The placeholder is used only while the new entry is **pending with no data**. It does not apply to a page that is already in the cache. A revisited page shows its own cached rows, with a background refetch if they are stale. ## What the hook returns while the next page loads | Field | Without a placeholder | With `keepPreviousData` | |---|---|---| | `status` | `'pending'` | `'success'` | | `isPending` | `true` | `false` | | `data` | `undefined` | the previous page's data | | `isPlaceholderData` | `false` | `true` | | `isFetching` | `true` | `true` | When the response arrives, `data` becomes the new page and `isPlaceholderData` returns to `false`. If the request fails, the placeholder is gone and the query reports `status: 'error'` like any failed query. ## Using isPlaceholderData in the UI The rows on screen during that window belong to the **old** page, and the UI should say so: 1. **Dim or mark the table** while `isPlaceholderData` is true, so users know the rows are about to change. 2. **Disable Next** while `isPlaceholderData` is true. Any `hasMore` flag in the data describes the previous page, and clicking again would move the key on before the user has seen the page they asked for. 3. **Keep the page label driven by your own `page` state**, not by the data, so the header already says "Page 4" while page 3's rows are dimmed. 4. **Prefetch the next page** once the current one is real: when `!isPlaceholderData && data.hasMore`, call `queryClient.prefetchQuery` for `['orders', page + 1]`. The next click then finds real data in the cache instead of a placeholder. ## What it does not do - It does **not** populate the cache for the new key. Only the real response does that. - It does **not** help the very first page. There is no previous data yet, so the first load is still `pending`. - It gives `dataUpdatedAt` no value from the old query. The v5 migration guide notes that with `placeholderData` the timestamp stays at `0`. - The `select` option also runs on placeholder data, so transformed data stays consistent during the swap. The same option works with `useInfiniteQuery` when its key changes, for example when a filter changes. Whether showing the old results is right is a UX decision, not a technical one. ## From v4 to v5 v4 had a dedicated `keepPreviousData: true` option and an `isPreviousData` flag. v5 removed both in favour of `placeholderData: keepPreviousData` and `isPlaceholderData`. Two behaviours changed. First, v4 reported the previous query's status, which could be `error`, while v5's placeholder always reports `success`. Second, v4 carried over the previous data's `dataUpdatedAt`, while v5 leaves it at `0`.
- What happens when the user jumps back to a page they already visited?That key already has a cache entry, so the hook returns its cached rows at once and `placeholderData` is not used. If the entry is stale, it refetches in the background with `isFetching` true. The placeholder applies only when the new key is pending with no data, which is why revisits look instant and first visits borrow the previous page.
- How do you make the Next button feel instant rather than merely not flashing?Prefetch the following page once the current one is real: when `!isPlaceholderData && data.hasMore`, call `queryClient.prefetchQuery({ queryKey: ['orders', page + 1], queryFn })`. When the user clicks Next, the key is already in the cache, so the table shows real rows immediately and no placeholder is needed.
- Would you use keepPreviousData when the key changes because of a new search term rather than a page number?It works for any key change, including filters and search terms, and with `useInfiniteQuery` too. Whether to use it is a UX call. Dimmed old results while new ones load suit pagination and filtering well. They mislead when the old data describes something different, such as another customer's record, where a clear loading state is more honest.
saying these in an interview costs you the question
- keepPreviousData: true still works in v5.
- placeholderData writes the previous page's rows into the new key's cache entry.
- While placeholder data shows, status is pending, so you can still show a spinner.
- Use initialData to carry the old page over to the new key.
- Copy data into useState to stop the table flashing.