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-liveinspector.
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.
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.
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.
Related
- Stale-While-Revalidate Implementation — the parent guide on the revalidation strategy these states come from.
- Tuning staleTime and gcTime — deriving the values that decide when “stale” begins.
- Preventing Request Waterfalls With Parallel Queries — removing the sequential loading that makes skeletons last longer.
- Concurrency & Race Conditions — server-side version guards for values that must not be acted on while stale.