Showing Stale Data While Revalidating in the UI

Stale-while-revalidate is a caching strategy and a user-interface problem in equal parts. The cache does its half automatically; the half that goes wrong is presentation — a list that blanks to a skeleton on every filter change, a spinner that appears twice a minute behind a dashboard nobody asked to refresh, or a stale price that looks exactly as authoritative as a fresh one. This page covers the presentation rules, under Stale-While-Revalidate Implementation. It assumes the values themselves are already derived rather than guessed, per Tuning staleTime and gcTime, and it pairs with Preventing Request Waterfalls With Parallel Queries, which removes the sequential loading that makes skeletons last longer than they should.

Diagnostic Checklist

Prerequisites

  • Queries with a deliberate staleTime, so “stale” means something.
  • A component that already distinguishes loading from error.
  • A way to test with a screen reader, or at least with an aria-live inspector.
Choosing a loading treatment A decision tree. If there is no data at all, show a skeleton. If there is data from a previous key, show it dimmed. If there is data for this key and a refetch is running, show a subtle refresh indicator, unless acting on stale data is consequential, in which case block. Four states, four treatments — the mistake is using one treatment for all four is there data for this key? no yes previous key's data? isPlaceholderData show it dimmed no layout shift skeleton the only true loading state refetching? fetchStatus subtle refresh hint keep the content visible
A skeleton is correct in exactly one branch. Every other branch already has something honest to show.

Step 1 — Branch on status, Not on a Combined Loading Flag

status === 'pending' means there is no data. fetchStatus === 'fetching' means a request is running. Only their combination identifies a genuine loading state.

import { useQuery } from '@tanstack/react-query';

export function BoardList() {
  const { data, status, fetchStatus, refetch } = useQuery({
    queryKey: ['boards', 'list'],
    queryFn: fetchBoards,
    staleTime: 30_000,
  });

  // No data AND a request running: the only case where a skeleton is honest.
  if (status === 'pending' && fetchStatus === 'fetching') return <BoardSkeleton />;
  // No data and no connectivity: a distinct state with distinct copy.
  if (status === 'pending' && fetchStatus === 'paused') return <OfflineNotice />;
  if (status === 'error') return <BoardError onRetry={() => refetch()} />;

  return (
    <section>
      <header>
        <h2>Boards</h2>
        {/* Data is on screen. A refetch is a hint, never a replacement. */}
        <RefreshHint active={fetchStatus === 'fetching'} />
      </header>
      <BoardGrid boards={data} />
    </section>
  );
}

Cache Behavior Analysis: The two axes are independent in v5 precisely so this branching is expressible: an entry in success status can be fetching indefinitely without the component ever losing its data, which is what stale-while-revalidate means at the component level. Collapsing them into a single isLoading — which in v5 is defined as status === 'pending' && fetchStatus === 'fetching', exactly the first branch here — is fine, but writing the condition explicitly makes the paused case visible rather than silently falling through to a spinner that never resolves. Because a fresh entry is not refetched on mount, navigating back to this screen inside staleTime renders instantly with no branch taken at all.


Step 2 — Carry Previous Data Across Key Changes

A filter change is a new key, so the cache correctly has no data for it. placeholderData bridges the gap without lying about what is on screen.

export function TodoSearch({ term }: { term: string }) {
  const { data, isPlaceholderData, fetchStatus } = useQuery({
    queryKey: ['todos', 'search', term],
    queryFn: ({ signal }) => searchTodos(term, signal),
    enabled: term.length > 1,
    // Carry the previous key's result into the new key while it loads.
    placeholderData: (previous) => previous,
    staleTime: 30_000,
  });

  return (
    <ul
      // Dim, do not hide. The rows are real, just one keystroke behind.
      style={{ opacity: isPlaceholderData ? 0.55 : 1, transition: 'opacity 120ms' }}
      aria-busy={fetchStatus === 'fetching'}
    >
      {data?.map((todo) => <li key={todo.id}>{todo.title}</li>)}
    </ul>
  );
}

Cache Behavior Analysis: placeholderData: (previous) => previous is v5’s replacement for keepPreviousData; the returned value is not written to the cache, it is only handed to this observer, so the new key’s entry stays genuinely empty until its own fetch resolves. That distinction matters: a placeholder never becomes cached data, never affects staleTime, and disappears the instant the real result lands. isPlaceholderData is how the component knows the difference, and dimming rather than hiding avoids the layout shift that makes fast typing feel jumpy — the row count is wrong for a moment, but the page does not jump.

Milliseconds of empty screen during a 12-keystroke search Bar chart of cumulative blank time. Blanking on every key change gives 1,340 milliseconds. Showing a skeleton gives 1,340 with a skeleton instead of nothing. placeholderData gives 120 milliseconds, only at the very first keystroke. Milliseconds of empty screen during a 12-keystroke search Cumulative time with no results rendered, 120ms median response blank between keys 1340 ms skeleton between keys 1340 ms placeholderData 120 ms
A skeleton does not reduce the time with no content — it only makes the absence prettier. Carrying previous data removes it.

Step 3 — Announce Refreshes Without Stealing Focus

A visual refresh hint is invisible to a screen reader, and a naive live region announces every poll. The middle path announces only user-initiated refreshes and content changes.

export function RefreshHint({ active, updatedAt }: { active: boolean; updatedAt?: number }) {
  return (
    <>
      {/* Decorative: hidden from assistive tech, reserved space so nothing shifts. */}
      <span
        aria-hidden="true"
        className={active ? 'refresh-dot refresh-dot--spinning' : 'refresh-dot'}
      />
      {/* Polite: queued behind whatever the user is doing, never interrupts. */}
      <span className="visually-hidden" aria-live="polite">
        {active ? 'Refreshing results' : updatedAt ? `Results updated ${formatAgo(updatedAt)}` : ''}
      </span>
    </>
  );
}

// Only announce refetches the user asked for.
export function ManualRefresh({ queryKey }: { queryKey: readonly unknown[] }) {
  const client = useQueryClient();
  const [announced, setAnnounced] = useState('');

  return (
    <>
      <button
        type="button"
        onClick={async () => {
          setAnnounced('Refreshing');
          await client.refetchQueries({ queryKey, exact: true });
          setAnnounced('Refreshed');
        }}
      >
        Refresh
      </button>
      <span className="visually-hidden" role="status">{announced}</span>
    </>
  );
}

Cache Behavior Analysis: refetchQueries forces a fetch regardless of staleTime, which is the correct semantics for a button labelled “Refresh” — invalidateQueries would mark the entry stale and then refetch it only if it has an active observer, which is a subtly different guarantee. Using aria-live="polite" rather than assertive means the announcement waits for a gap in whatever the user is doing, so a background poll cannot interrupt someone mid-sentence in a form. The reserved space for the spinner matters as much as the announcement: an indicator that appears and disappears changes layout twice per poll, which is disorienting for everyone and actively disruptive for users with cognitive or motor impairments.


Step 4 — Escalate When Staleness Becomes Misleading

Some values must not be acted on while stale. For those, the correct treatment is to block rather than to hint.

export function CheckoutTotal({ cartId }: { cartId: string }) {
  const { data, isStale, fetchStatus } = useQuery({
    queryKey: ['checkout', 'total', cartId],
    queryFn: () => fetchCheckoutTotal(cartId),
    // Short, because acting on an old price is a real problem.
    staleTime: 10_000,
    refetchOnWindowFocus: true,
  });

  // Stale AND about to be acted on: block rather than present a confident number.
  const blocked = isStale || fetchStatus === 'fetching';

  return (
    <div>
      <p className={blocked ? 'total total--pending' : 'total'}>
        {data ? formatMoney(data.amount) : '—'}
      </p>
      <button type="button" disabled={blocked}>
        {blocked ? 'Confirming price…' : 'Place order'}
      </button>
    </div>
  );
}

Cache Behavior Analysis: isStale is exposed per observer and evaluates against that query’s staleTime, so this component can adopt a stricter posture than the rest of the application without changing any global default. Disabling the action rather than hiding the number keeps the screen readable while making it impossible to act on a value the cache is no longer confident about — which is the honest presentation of stale-while-revalidate for consequential data. Where the consequence is severe enough, the stronger form is to make the mutation itself carry the version it was computed from, so the server rejects a stale submission, as covered in Concurrency & Race Conditions.

Choosing a treatment by what the user might do with the value Matrix of four screen types mapped to the correct staleness treatment and the reason. Choosing a treatment by what the user might do with the value Treatment Why Dashboard behind a poll No indicator at all Nobody asked for the refresh and nothing is acted on Search results while typing Previous results, dimmed The rows are real, just one keystroke behind Manually refreshed list Visible hint plus a polite announcement The user asked, so acknowledge it Price, stock or balance Disable the action until fresh Acting on the stale value has consequences
The question is never how stale the data is — it is what happens if the user acts on it.

Edge Cases & Gotchas

Placeholder data and pagination. With placeholderData, a page change shows the previous page’s rows until the new ones arrive, which is usually right — but the page-number control must reflect the requested page, not the rendered one, or the two disagree visibly.

Suspense removes the choice. useSuspenseQuery has no pending state to branch on; it suspends. That means the previous content is unmounted by the boundary unless you wrap the transition in startTransition, which keeps the old tree mounted while the new one loads.

Error while stale data is on screen. A failed background refetch leaves status === 'error' with the previous data still present. Blanking the screen for a failed refresh is worse than keeping the old content with an inline warning.


Common Pitfalls & Resolutions

Observable Issue Root Cause Diagnostic Resolution
The list blanks on every filter change The new key has no data, so status returns to pending Add placeholderData: (previous) => previous
A spinner appears twice a minute The component branches on fetchStatus alone Only show a skeleton when status === 'pending' too
Screen readers announce every poll A live region is bound to fetchStatus Announce user-initiated refreshes and content changes only
Layout jumps when refreshing starts The indicator is inserted and removed from flow Reserve its space and toggle a class
A failed refresh wipes good content The error branch runs before the data branch Keep data on screen and show an inline warning

Frequently Asked Questions

When should a background refetch be visible at all?

When the user asked for it, or when the data on screen could plausibly be wrong in a way that matters to them. A dashboard polling every thirty seconds needs no indicator — showing one trains people to ignore it, and it competes for attention with content they are actually reading. A refresh the user clicked needs acknowledgement, both visually and for assistive technology, because otherwise the button appears not to work. And a value they are about to act on needs more than an indicator; it needs the action gated.

Why does my list flash empty between filter changes?

Because a filter is part of the query key, so changing it addresses a different cache entry — one that has no data yet. That entry’s status is pending, which is entirely correct from the cache’s point of view: it genuinely has nothing for that key. placeholderData: (previous) => previous hands the observer the previous key’s result while the new one loads, without writing it to the new entry, so the screen keeps its content and isPlaceholderData tells you to dim it. This is one of the few UI problems where the fix is a single option.

Is showing stale data ever unacceptable?

Yes, and the test is consequence rather than age. A stale project description is harmless; a stale price at checkout, a stale remaining-stock count, a stale permission state or a stale account balance are all values a user may act on irreversibly. For those, the honest treatment is to block the action until the data is fresh, and where the stakes are highest, to have the mutation carry the version it was computed against so the server can reject a submission built on stale input.