Skip to content

Fix: TanStack Query (React Query) Returning Stale Data — Cache Not Updating

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix TanStack Query returning stale cached data — staleTime, invalidateQueries, query key structure, optimistic updates, and cache synchronization after mutations.

The Problem

A TanStack Query (React Query) fetch returns old data even after the server has updated:

const { data } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
});
// data shows the old username even after the user updated their profile

Or a mutation succeeds but the UI doesn’t reflect the change:

const mutation = useMutation({
  mutationFn: updateUser,
  onSuccess: () => {
    // This is called, but the useQuery above still shows old data
    console.log('Mutation succeeded');
  },
});

Or the query refetches every time a component mounts, causing unnecessary network requests:

GET /api/users/42  (on mount)
GET /api/users/42  (on window focus)
GET /api/users/42  (on mount again after tab switch)

Why This Happens

TanStack Query uses an aggressive caching strategy. Understanding the distinction between staleTime and gcTime is critical, because confusing them is behind most “stale data” bugs.

staleTime controls how long data is considered fresh after a fetch. While data is fresh, TanStack Query serves the cached value and does not trigger a background refetch — not on mount, not on window focus, not on network reconnect. Once staleTime expires, the data is marked stale. Stale data is still served from cache, but TanStack Query starts a background refetch on the next trigger (mount, focus, etc.). The default staleTime is 0, which means data is stale immediately after fetching.

gcTime (formerly cacheTime) controls how long unused data stays in memory after all components using it unmount. The default is 5 minutes. During this window, if a component remounts, it gets the cached data instantly (while a background refetch runs if the data is stale). After gcTime expires, the data is garbage-collected and the next mount starts fresh.

Specific failure patterns:

  • staleTime defaults to 0 — data is considered stale immediately after fetching. A stale query refetches in the background when the component mounts or the window regains focus, but still shows the cached value until the refetch completes. This can look like stale data even though a refetch is in progress.
  • Cache persists across component unmounts — queries are cached by queryKey. The cache doesn’t clear when the component using the query unmounts. On next mount, cached data is shown immediately, then refetched if stale.
  • Mutations don’t automatically invalidate queries — a mutation that updates server data doesn’t automatically tell TanStack Query to refetch related queries. You must explicitly call invalidateQueries or update the cache manually.
  • Query key mismatch — if the mutation invalidates ['user'] but the query uses ['user', userId], the invalidation doesn’t match and the cache isn’t cleared.
  • gcTime keeps data in memory — even after a query becomes unused (no mounted components), data stays in cache for gcTime milliseconds (default: 5 minutes). A new mount reuses this cached data.

Diagnostic Timeline

Your first instinct is “set staleTime: 0” — but that is already the default, and it causes constant refetching. This timeline walks through identifying why cached data appears stale and which configuration change actually fixes it.

Minute 0 — Install React Query Devtools. Open the devtools panel and find the query in question. Check its status:

npm install @tanstack/react-query-devtools
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

<QueryClientProvider client={queryClient}>
  <App />
  <ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>

In the devtools panel, locate your query by its key. The panel shows: status (fresh, stale, fetching), last updated timestamp, data preview, and observer count (how many components are subscribed).

Minute 2 — Check if the query key is stable. A common cause of “stale data” is an unstable query key. If the key contains a new object reference on every render, TanStack Query treats it as a different query each time:

// WRONG — new object on every render creates a new cache entry each time
const { data } = useQuery({
  queryKey: ['users', { filters }],  // if `filters` is a new object each render
  queryFn: () => fetchUsers(filters),
});

TanStack Query compares keys by deep equality (not reference equality), so { page: 1 } and { page: 1 } are the same key. But if filters contains unstable nested objects or functions, the comparison may fail. Check the devtools — if you see multiple entries for what should be the same query, the key is unstable.

Minute 5 — Verify the mutation invalidates the correct key. After the mutation fires, check the devtools again. If the query’s status does not change from stale to fetching, the invalidation did not match:

// Mutation invalidates ['user'] but query key is ['user', 42]
// Does this match? Yes — invalidateQueries uses prefix matching by default
queryClient.invalidateQueries({ queryKey: ['user'] });
// This matches ['user'], ['user', 42], ['user', 42, 'posts'], etc.

If you used exact: true, only the exact key matches. Remove exact: true and test again.

Minute 8 — Check if queryFn returns fresh data. The query function might be returning cached data from an upstream layer (browser HTTP cache, service worker, CDN). Add a cache-busting header or log the fetch:

queryFn: async () => {
  const res = await fetch(`/api/users/${userId}`, {
    headers: { 'Cache-Control': 'no-cache' },
  });
  const data = await res.json();
  console.log('Fetched from server:', data);
  return data;
},

If the console log shows old data, the problem is not TanStack Query — it is the HTTP layer caching the response.

Minute 12 — Verify QueryClient is not recreated on render. If new QueryClient() is called inside a component body, every render creates a fresh cache, losing all previous data. The client must be created outside the component or memoized.

Fix 1: Invalidate Queries After Mutations

The standard pattern — after a mutation succeeds, tell TanStack Query to refetch related queries:

import { useMutation, useQueryClient } from '@tanstack/react-query';

function UserProfile({ userId }: { userId: number }) {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: (data: UpdateUserData) => updateUser(userId, data),
    onSuccess: () => {
      // Invalidate the query — marks it as stale and triggers a refetch
      queryClient.invalidateQueries({ queryKey: ['user', userId] });

      // To invalidate ALL user queries (any userId):
      // queryClient.invalidateQueries({ queryKey: ['user'] });
    },
  });

  return (
    <button onClick={() => mutation.mutate({ name: 'New Name' })}>
      Update
    </button>
  );
}

invalidateQueries with exact: false (default) — matches any query whose key starts with the given prefix. ['user'] matches ['user', 1], ['user', 2], ['user', 'profile', 1], etc.

invalidateQueries with exact: true — only matches the exact key:

// Invalidates ONLY ['user', userId] — not ['user'] or ['user', userId, 'posts']
queryClient.invalidateQueries({ queryKey: ['user', userId], exact: true });

Fix 2: Set staleTime to Prevent Unnecessary Refetches

If data changes rarely, set staleTime to avoid constant background refetches:

const { data } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
  staleTime: 5 * 60 * 1000,  // 5 minutes — don't refetch for 5 minutes after fetch
});
// Global default — apply to all queries
import { QueryClient } from '@tanstack/react-query';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000,           // 1 minute
      gcTime: 10 * 60 * 1000,         // 10 minutes (keep in cache)
      refetchOnWindowFocus: false,     // Don't refetch when tab is focused
      refetchOnMount: false,           // Don't refetch on every component mount
      retry: 1,                        // Retry once on failure
    },
  },
});

Choose staleTime based on data freshness requirements:

Data typeRecommended staleTime
User profile5-15 minutes
Product catalog1-24 hours
Real-time stock price0 (always stale)
Static configInfinity (never stale)
Notifications count30-60 seconds
// Static/rarely-changing data — never refetch automatically
const { data: config } = useQuery({
  queryKey: ['config'],
  queryFn: fetchConfig,
  staleTime: Infinity,   // Never becomes stale
  gcTime: Infinity,      // Never removed from cache
});

Fix 3: Use Optimistic Updates for Instant UI

Instead of waiting for the server refetch after a mutation, update the cache immediately with the expected new value:

const queryClient = useQueryClient();

const mutation = useMutation({
  mutationFn: (newName: string) => updateUserName(userId, newName),

  // Called before the mutation fires — update cache optimistically
  onMutate: async (newName) => {
    // Cancel any in-flight refetches (avoid overwriting optimistic update)
    await queryClient.cancelQueries({ queryKey: ['user', userId] });

    // Snapshot the current value (for rollback on error)
    const previousUser = queryClient.getQueryData(['user', userId]);

    // Optimistically update the cache
    queryClient.setQueryData(['user', userId], (old: User) => ({
      ...old,
      name: newName,
    }));

    // Return context for potential rollback
    return { previousUser };
  },

  // If mutation fails, roll back to the previous value
  onError: (err, newName, context) => {
    if (context?.previousUser) {
      queryClient.setQueryData(['user', userId], context.previousUser);
    }
  },

  // After success or error, refetch to sync with server
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['user', userId] });
  },
});

The UI updates instantly on mutation. If the server rejects it, the cache rolls back.

Fix 4: Manually Update the Cache After Mutation

For mutations where you know the server response, update the cache directly without a refetch:

const mutation = useMutation({
  mutationFn: (data: UpdateUserData) => updateUser(userId, data),
  onSuccess: (updatedUser) => {
    // Server returns the updated user — set it directly in cache
    // No refetch needed — the cache now has the correct data
    queryClient.setQueryData(['user', userId], updatedUser);

    // If updating an item in a list query:
    queryClient.setQueryData(['users'], (old: User[]) =>
      old.map(u => u.id === userId ? updatedUser : u)
    );
  },
});

This approach is faster than invalidateQueries because it avoids an extra network request.

Pro Tip: Use setQueryData when the mutation response contains the full updated object. Use invalidateQueries when the mutation only returns a success status and you need to fetch the current state from the server.

Fix 5: Fix Query Key Structure to Ensure Proper Invalidation

Query keys are the cache key. A mismatch between the mutation’s invalidation target and the query’s key means the cache won’t be cleared:

// WRONG — key mismatch causes invalidation to miss
const { data } = useQuery({
  queryKey: ['users', { id: userId }],    // Object in key
  queryFn: () => fetchUser(userId),
});

// This doesn't match ['users', { id: userId }]
queryClient.invalidateQueries({ queryKey: ['users', userId] });  // Number, not object
// CORRECT — consistent key structure

// Define keys in one place (prevents mismatch)
const userKeys = {
  all: ['users'] as const,
  lists: () => [...userKeys.all, 'list'] as const,
  detail: (id: number) => [...userKeys.all, 'detail', id] as const,
  posts: (id: number) => [...userKeys.detail(id), 'posts'] as const,
};

// In queries
const { data } = useQuery({
  queryKey: userKeys.detail(userId),   // ['users', 'detail', 42]
  queryFn: () => fetchUser(userId),
});

// In mutations
queryClient.invalidateQueries({ queryKey: userKeys.detail(userId) });  // Same key
queryClient.invalidateQueries({ queryKey: userKeys.all });             // Invalidates all user queries

Hierarchical keys make partial invalidation easy:

// Invalidate just the user's posts, not their profile
queryClient.invalidateQueries({ queryKey: userKeys.posts(userId) });

// Invalidate all queries for this user (profile + posts + settings)
queryClient.invalidateQueries({ queryKey: userKeys.detail(userId) });

// Invalidate ALL user queries (all users, all list views)
queryClient.invalidateQueries({ queryKey: userKeys.all });

Fix 6: Force a Refetch for Specific Cases

When you need to guarantee fresh data (user clicks “refresh” button, or after specific events):

const { data, refetch } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
});

// Manually trigger a refetch — bypasses staleTime
<button onClick={() => refetch()}>Refresh</button>

refetchQueries from outside a component:

// Force refetch all user queries — useful from event handlers or non-React code
queryClient.refetchQueries({ queryKey: ['user'] });

// Refetch and wait for completion
await queryClient.refetchQueries({ queryKey: ['user', userId] });

Refetch on specific window events:

const { data } = useQuery({
  queryKey: ['notifications'],
  queryFn: fetchNotifications,
  refetchInterval: 30 * 1000,     // Poll every 30 seconds
  refetchIntervalInBackground: true,  // Continue polling when tab is in background
});

Fix 7: Debug Cache State

Inspect what TanStack Query has in its cache:

// Log the entire cache
console.log(queryClient.getQueryCache().getAll());

// Get data for a specific query
const cachedUser = queryClient.getQueryData(['user', userId]);
console.log('Cached user:', cachedUser);

// Check query state
const queryState = queryClient.getQueryState(['user', userId]);
console.log('Status:', queryState?.status);          // 'pending' | 'success' | 'error'
console.log('Last updated:', queryState?.dataUpdatedAt);
console.log('Is stale:', queryState?.isInvalidated);

Install React Query Devtools — the fastest way to debug cache state:

npm install @tanstack/react-query-devtools
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <YourApp />
      <ReactQueryDevtools initialIsOpen={false} />
      {/* Opens a panel showing all queries, their state, and cached data */}
    </QueryClientProvider>
  );
}

The devtools panel shows every query’s status, data, staleTime countdown, and lets you manually invalidate or remove queries.

Still Not Working?

Verify QueryClient is not recreated on every render. Creating new QueryClient() inside a component or at the module level without memoization wipes the cache on every render:

// WRONG — new client on every render
function App() {
  const queryClient = new QueryClient();  // ← Cache wiped every render
  return <QueryClientProvider client={queryClient}>...</QueryClientProvider>;
}

// CORRECT — create once outside the component
const queryClient = new QueryClient();

function App() {
  return <QueryClientProvider client={queryClient}>...</QueryClientProvider>;
}

Multiple QueryClientProvider instances — if two providers exist at different levels of the tree, queries in each subtree use separate caches. Invalidating from one doesn’t affect the other.

select option transforms data without affecting the cache — if you use select to transform query data, the cache still contains the original server response. Invalidation works correctly, but getQueryData returns the pre-transform data:

const { data: userName } = useQuery({
  queryKey: ['user', userId],
  queryFn: fetchUser,
  select: (user) => user.name,  // data is the name string
});

// But cache still has the full User object:
queryClient.getQueryData(['user', userId]);  // Returns full User, not just name

enabled: false prevents automatic fetching — if a query has enabled: false, it never fetches automatically. It only fetches when you call refetch() manually. If you conditionally set enabled based on a variable that is falsy on first render, the query stays idle and never loads data. Check the devtools — if the query status is idle or pending without any fetch attempt, enabled is likely false.

Stale closure in queryFn captures old state — if the query function references a React state variable via closure, it captures the value at the time the function was created. When the query refetches, it may call the API with an outdated parameter:

// WRONG — stale closure captures old userId
const [userId, setUserId] = useState(1);

const { data } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),  // ← userId from closure
});

// When userId changes from 1 to 2, the queryKey changes to ['user', 2],
// which creates a new query. This is correct.
// But if queryFn had a bug that reused a stale ref, the fetch would be wrong.

The fix is to always derive the fetch parameters from the queryKey, not from component state:

queryKey: ['user', userId],
queryFn: ({ queryKey }) => fetchUser(queryKey[1] as number),

For related frontend issues, see Fix: Redux State Not Updating, Fix: React useEffect Runs Twice, Fix: React useEffect Infinite Loop, and Fix: React useState Not Updating.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles