Skip to content

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

FixDevs ·

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:

  • 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 (formerly cacheTime) 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.

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

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

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