Fixing React Query Stale Data Showing After a Successful Mutation

May 24, 2026 6 min read 50 views
Minimalist illustration of a data cache with stale and refreshed cards, representing client-side state management in a React application

You submit a form, the mutation resolves with a 200, but the list on screen still shows the old record. You refresh and everything is correct. That gap between server truth and UI state is a React Query cache problem, and it has a handful of well-defined solutions.

This article walks through the most common causes and the exact fixes. No hand-waving β€” you'll see real code patterns you can drop straight into your project.

What you'll learn

  • Why React Query holds onto stale data even after a successful mutation
  • How to use invalidateQueries to trigger a background refetch
  • How to write directly to the cache for an instant UI update
  • How to implement optimistic updates safely, including rollback on error
  • Common mistakes that silently break cache invalidation

Prerequisites

These examples use TanStack Query v5 (the package formerly known as React Query). The patterns are almost identical in v4, but the useMutation callback signatures differ slightly. You should be comfortable with basic useQuery and useMutation hooks before continuing.

Why Stale Data Appears in the First Place

React Query is a client-side cache. When you call useQuery, it stores the result under a query key. By default, that cached value is considered stale immediately after it is fetched β€” but stale does not mean gone. React Query still serves the stale value from the cache while it decides whether to refetch.

A mutation knows nothing about your queries unless you explicitly connect them. When useMutation succeeds, React Query does not automatically know which cached queries are now invalid. That is your job. Until you tell it otherwise, the cache happily serves the old data.

This is a deliberate design choice, not a bug. It keeps the library flexible. Your job is to bridge the gap.

The Simplest Fix: invalidateQueries

The fastest way to synchronise the cache after a mutation is to call queryClient.invalidateQueries inside the onSuccess callback. This marks the matching queries as stale and triggers a background refetch immediately.

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

export function useUpdateTodo() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: updateTodo,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });
}

The key ['todos'] acts as a prefix. Any query whose key starts with 'todos' β€” including ['todos', 1], ['todos', 'active'] β€” will be invalidated. This is usually what you want: invalidate broadly rather than surgically, unless you have a performance reason to be specific.

Exact key matching when you need it

If you only want to invalidate a single specific query, add exact: true.

queryClient.invalidateQueries({
  queryKey: ['todos', todoId],
  exact: true,
});

Use exact matching sparingly. If you add a new list query later and forget to update the invalidation, you will recreate the same stale-data bug.

Writing Directly to the Cache with setQueryData

Calling invalidateQueries triggers a network request. If you already have the updated data in the mutation response, you can skip the round trip and write directly into the cache with setQueryData.

return useMutation({
  mutationFn: updateTodo,
  onSuccess: (updatedTodo) => {
    // Update a single item query
    queryClient.setQueryData(['todos', updatedTodo.id], updatedTodo);

    // Also invalidate the list so it re-fetches fresh order/filters
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  },
});

This pattern gives you the best of both worlds: the detail view updates instantly from the mutation response, and the list refetches in the background. The user sees no flash of old content.

Optimistic Updates: Update the UI Before the Server Responds

For interactions that feel slow β€” toggling a checkbox, reordering a list β€” you can update the cache before the mutation resolves and roll back if it fails. React Query calls this an optimistic update.

return useMutation({
  mutationFn: toggleTodo,

  onMutate: async (toggledTodo) => {
    // Cancel any in-flight refetches so they don't overwrite our optimistic state
    await queryClient.cancelQueries({ queryKey: ['todos'] });

    // Snapshot the current value so we can roll back on error
    const previousTodos = queryClient.getQueryData(['todos']);

    // Optimistically update the cache
    queryClient.setQueryData(['todos'], (old) =>
      old.map((todo) =>
        todo.id === toggledTodo.id
          ? { ...todo, completed: !todo.completed }
          : todo
      )
    );

    // Return context for the onError callback
    return { previousTodos };
  },

  onError: (err, toggledTodo, context) => {
    // Roll back to the snapshot
    queryClient.setQueryData(['todos'], context.previousTodos);
  },

  onSettled: () => {
    // Always refetch to make sure we're in sync
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  },
});

The onMutate callback runs synchronously before the network request. Returning the previous data as context passes it through to onError, where you use it to restore the cache if the server rejects the change. The onSettled hook fires whether the mutation succeeded or failed, giving you a clean place to do a final sync.

Common Pitfalls That Break Cache Invalidation

Query key mismatch

This is the most common cause of invalidation silently doing nothing. If your useQuery key is ['todos', { status: 'active' }] but you invalidate ['todos'] with exact: true, nothing happens. Always double-check that the key you pass to invalidateQueries matches or is a prefix of the key used in the query.

Using a new queryClient instance in the mutation

Calling useQueryClient() inside a utility function that runs outside a React component will return a different instance if you have not set up the provider correctly. Always get the client from the hook inside a component or a custom hook, never by importing a bare new QueryClient().

Forgetting async/await in onMutate

If you skip the await on cancelQueries inside onMutate, an in-flight refetch can overwrite your optimistic state before the mutation completes. Always await it.

// Wrong β€” missing await
onMutate: (todo) => {
  queryClient.cancelQueries({ queryKey: ['todos'] }); // fire and forget
  queryClient.setQueryData(['todos'], ...);
},

// Correct
onMutate: async (todo) => {
  await queryClient.cancelQueries({ queryKey: ['todos'] });
  queryClient.setQueryData(['todos'], ...);
},

staleTime set too high

If you have configured a long staleTime β€” say, five minutes β€” and you invalidate a query, React Query will only refetch if the query has an active observer (a mounted component using useQuery). If the component is not mounted, the data will be refreshed next time it mounts. That is usually fine, but be aware of it when debugging.

Choosing the Right Strategy

SituationRecommended approach
Mutation response does not include updated datainvalidateQueries only
Mutation response includes the full updated resourcesetQueryData + invalidateQueries for the list
Interaction must feel instant (toggle, drag, like)Optimistic update with rollback
Multiple unrelated queries need refreshingMultiple invalidateQueries calls in onSettled

Start with invalidateQueries. It is safe, predictable, and covers 80% of cases. Reach for setQueryData or optimistic updates only when you have a measurable UX reason.

Structuring Mutations at Scale

Once you have more than a handful of mutations, keeping the cache logic consistent gets tricky. A clean pattern is to colocate each mutation in its own custom hook and keep all invalidation logic inside that hook.

// hooks/usCreateTodo.js
export function useCreateTodo() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: createTodo,
    onSuccess: (newTodo) => {
      queryClient.setQueryData(['todos', newTodo.id], newTodo);
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });
}

// hooks/useDeleteTodo.js
export function useDeleteTodo() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: deleteTodo,
    onSuccess: (_, deletedId) => {
      queryClient.removeQueries({ queryKey: ['todos', deletedId] });
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });
}

Each hook owns its side effects. When something breaks, you know exactly where to look.

Wrapping Up

Stale data after a mutation is almost always a missing or mismatched invalidation. Here are the concrete steps to take from here:

  • Audit your query keys. Log them with queryClient.getQueryCache().getAll().map(q => q.queryKey) and confirm your invalidation keys match.
  • Start with invalidateQueries in onSuccess for every mutation that changes server state. Add complexity only if you need it.
  • Use setQueryData when your mutation response already contains the updated resource to avoid an unnecessary round trip.
  • Add the React Query DevTools (@tanstack/react-query-devtools) to your dev build. You can watch cache entries update in real time and spot stale keys instantly.
  • Write one custom hook per mutation and keep all cache logic inside it. This makes debugging and refactoring much easier as your app grows.

πŸ“€ Share this article

Sign in to save

Comments (0)

No comments yet. Be the first!

Leave a Comment

Sign in to comment with your profile.

πŸ“¬ Weekly Newsletter

Stay ahead of the curve

Get the best programming tutorials, data analytics tips, and tool reviews delivered to your inbox every week.

No spam. Unsubscribe anytime.