skip to content

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?

level: middleimportance: must knowfreq 60%

answer

  1. every page is a new key
  2. a new key starts with no data
  3. borrow data from the last key
  4. an identity function the library exports
  5. a flag says the data is borrowed

basics

~20 s

Each 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 s

Because 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 lines
tsx
import { 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

for a junior

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.

for a middle

Explain why the flash happens, what status, isPending and isPlaceholderData report during the swap, and why the placeholder never lands in the cache.

for a senior

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.

for a principal

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.