React Query Showing Stale Data? Causes and Fixes (v5)
React Query showing old data on first render? Fix initialData treated as fresh, incomplete query keys, and v4 leftovers like keepPreviousData in Query v5.

React Query shows stale data when the cache already holds an entry that it considers fresh, so it renders that entry and skips the fetch. In TanStack Query v5 the three most common causes are initialData combined with staleTime, a query key that does not include every variable the request depends on, and caching options carried over from v4. Navigating away and back often hides the bug, because the component remounts after the entry has gone stale.
Quick Diagnosis
| Symptom | Likely cause | Fix |
|---|---|---|
| Empty or default data on first render, correct after navigation | initialData plus staleTime marks the seed data as fresh | Use placeholderData, or set initialDataUpdatedAt |
| Data from the previous user, page, or filter | Query key missing a dependency | Put every input of queryFn in queryKey |
| Old data after a mutation | Cache not invalidated | invalidateQueries with the key prefix |
| Table flashes empty while paginating after upgrading to v5 | keepPreviousData option was removed in v5 | placeholderData: keepPreviousData |
| Totals wrong after adding or moving an item | Aggregate query key missing the structure it depends on | Add the structure to the key, invalidate the aggregate prefix |
| Data never refreshes when returning to the tab | staleTime too long or refetchOnWindowFocus disabled | Lower staleTime or refetch on focus |
Why Does React Query Show Old Data on the First Render?
Staleness is decided per cache entry. staleTime defines how long an entry counts as fresh, and its default is 0, so data is stale right after it arrives unless you raise it. When a component mounts, React Query refetches only if the entry is stale. Anything that makes a wrong entry look fresh gets rendered as is.
This is the exact bug I hit in a paginated dashboard. I seeded the query with an empty initialData object so the table had something to render, and I added a five minute staleTime to cut down on requests. The first load showed an empty table. Navigating away and coming back showed the real rows.
The cause is that initialData is written into the cache and treated like data that was just fetched. With staleTime set to five minutes, React Query believed the empty object was fresh and had no reason to call the API.
Fix 1: Use placeholderData for Loading States, Not initialData
placeholderData renders while the query loads but is never persisted to the cache, so it cannot block the real fetch. The isPlaceholderData flag tells you when it is on screen.
import { useQuery } from "@tanstack/react-query";
const emptyPage = { currentPage: 0, totalItems: 0, totalPages: 0, data: [] };
const { data, isPlaceholderData } = useQuery({
queryKey: ["dataList", { userId, page, pageSize }],
queryFn: () => fetchData({ userId, page, pageSize }),
placeholderData: emptyPage,
staleTime: 1000 * 60 * 5,
});If you really need initialData, for example data rendered on the server, tell React Query how old it is with initialDataUpdatedAt. An old timestamp, or 0, marks the entry as stale so the query refetches on mount.
useQuery({
queryKey: ["dataList", { userId, page, pageSize }],
queryFn: () => fetchData({ userId, page, pageSize }),
initialData: serverData,
initialDataUpdatedAt: serverFetchedAt, // use 0 to always refetch on mount
staleTime: 1000 * 60 * 5,
});Fix 2: Include Every Dependency in the Query Key
The query key is the cache address. If queryFn reads userId, page, and pageSize but the key is only [“dataList”], every user and page shares one cache entry, and you see whichever response landed last.
// Wrong: page 2 reuses the cache entry from page 1
useQuery({ queryKey: ["dataList"], queryFn: () => fetchData({ userId, page }) });
// Right: each combination gets its own entry
useQuery({
queryKey: ["dataList", { userId, page, pageSize }],
queryFn: () => fetchData({ userId, page, pageSize }),
});The official TanStack Query ESLint plugin ships an exhaustive-deps rule for query keys that catches this at lint time.
Fix 3: Replace keepPreviousData With placeholderData in v5
TanStack Query v5 removed the keepPreviousData option and the isPreviousData flag. According to the official v5 migration guide, the same behavior now comes from passing the keepPreviousData helper to placeholderData.
import { keepPreviousData, useQuery } from "@tanstack/react-query";
const { data, isPlaceholderData } = useQuery({
queryKey: ["dataList", { userId, page, pageSize }],
queryFn: () => fetchData({ userId, page, pageSize }),
placeholderData: keepPreviousData,
});One behavior change matters here. With placeholderData the query stays in a success state while the next page loads, while v4 keepPreviousData could expose an error status from the previous query. Use isPlaceholderData to dim the table or disable the next page button.
Fix 4: Invalidate After Mutations
A mutation changes data on the server, but the cache does not know that. Invalidate the affected keys when the mutation succeeds. In v5, invalidateQueries takes an object, and a key prefix matches every query that starts with it.
import { useMutation, useQueryClient } from "@tanstack/react-query";
const queryClient = useQueryClient();
const updateItem = useMutation({
mutationFn: saveItem,
onSuccess: () => {
// Marks every ["dataList", ...] query stale and refetches the active ones
queryClient.invalidateQueries({ queryKey: ["dataList"] });
},
});Pass exact: true when only the bare key should refetch. Without it, ["dataList"] also matches every child key such as ["dataList", { page: 2 }], so a mutation on one page refetches all of them.
Avoid calling invalidateQueries inside a useEffect that runs whenever the key changes. A new key already triggers a fetch, so the effect only adds duplicate requests.
Totals Go Stale When the List Changes
Totals, counts, and per-group summaries are derived from a list. If the list changes shape (an item is added, moved, or removed) but the aggregate’s query key stays the same, React Query has no reason to refetch it. The page keeps showing numbers for a structure that no longer exists.
The two fixes combine well:
- Put the structure the aggregate depends on into its key, so a new structure becomes a new cache entry.
- Invalidate the aggregate in the mutation that changed the structure, for changes that alter the numbers without changing the ids.
// Wrong: totals are keyed by company only, but they depend on which branches exist
useQuery({
queryKey: ["branchTotals", companyId],
queryFn: () => fetchBranchTotals(companyId, branchIds),
});
// Right: the key includes the branch ids the totals were computed from
useQuery({
queryKey: ["branchTotals", companyId, branchIds],
queryFn: () => fetchBranchTotals(companyId, branchIds),
});Sort branchIds before building the key, so the same set of branches always maps to the same entry. The key fixes the case where the set of ids changes. It does not fix a move that keeps the same ids but changes each branch’s numbers, which is why the invalidation below stays.
const moveBranch = useMutation({
mutationFn: saveBranchMove,
onSuccess: () => {
// Structure changed: refetch the tree and every totals query for this company
queryClient.invalidateQueries({ queryKey: ["branchTree", companyId] });
queryClient.invalidateQueries({ queryKey: ["branchTotals", companyId] });
},
});staleTime vs gcTime: What Is the Difference?
staleTime controls when data needs refetching. gcTime, called cacheTime before v5, controls when unused data is removed from memory.
| Option | Default | What it controls | Raise it when |
|---|---|---|---|
| staleTime | 0 | How long data counts as fresh, so no refetch on mount or window focus | Data rarely changes and you want fewer requests |
| gcTime | 5 minutes | How long an unused entry stays cached after its last observer unmounts | Users often return to the same screen |
A high staleTime never makes data fresher. It does the opposite, it tells React Query to trust the cache for longer.
Fix 5: Control When Refetches Happen
If a screen must always show current data, force a refetch on mount regardless of staleTime, and refetch when the user returns to the tab.
useQuery({
queryKey: ["dataList", { userId, page, pageSize }],
queryFn: () => fetchData({ userId, page, pageSize }),
refetchOnMount: "always",
refetchOnWindowFocus: true,
});For dashboards that need to stay live, add refetchInterval with a value in milliseconds.
Also Read: Zustand: Lightweight State Management for Modern React Apps
How to Confirm the Cause With React Query Devtools
Open React Query Devtools and select the query. Three details answer most stale data questions:
- The query key. If two screens show the same key, they share one cache entry.
- The status badge. A fresh badge on data you know is wrong points to initialData plus staleTime.
- The last updated time. A timestamp from before the page loaded means the data came from cache, not a new request.
Also Read: Server Actions vs Client Rendering in Next.js: The 2025 Guide
Frequently Asked Questions
Why does navigating away and back fix stale data in React Query?
Remounting runs the refetch on mount check again, and by then the entry may have gone stale, so a fetch happens. It hides the real cause, which is usually initialData treated as fresh or a query key missing a dependency.
Is staleTime 0 by default in TanStack Query v5?
Yes. staleTime defaults to 0 and gcTime defaults to 5 minutes, so data is considered stale right after it is fetched unless you configure otherwise.
What replaced keepPreviousData in TanStack Query v5?
Import the keepPreviousData helper from @tanstack/react-query and pass it to placeholderData. The isPreviousData flag became isPlaceholderData.
Does initialData trigger a fetch on mount?
Only when it is stale. initialData is stored in the cache, so with a non zero staleTime it counts as fresh and blocks the fetch, unless initialDataUpdatedAt says it is older than staleTime.
Checklist Before You Ship
- Loading states use placeholderData, not initialData.
- Every value used by queryFn appears in queryKey.
- Mutations invalidate the key prefixes they affect.
- Totals and counts include the structure they depend on in their key, or are invalidated when it changes.
- v4 options are migrated: cacheTime to gcTime, keepPreviousData to placeholderData.
- staleTime matches how often the data really changes.


