React JsJuly 3, 2026

Why Your React Query Cache Isn't Invalidating (Fix It Now)

React Query cache not updating after mutations? Master cache invalidation with exact:false, query key matching, and real-world debugging patterns.

Why Your React Query Cache Isn't Invalidating (Fix It Now)

You've built a killer React app. Data fetches fine. But after you update something? The UI doesn't refresh. Your mutation works (the server confirmed it), but the UI is showing stale data like nothing happened. Sound familiar?

Here's the thing: React Query cache invalidation isn't broken—it's just picky about how you call it. Most of the time, cache invalidation fails because of a mismatch between how you define your query keys and how you're trying to invalidate them. There's a specific gotcha that trips up nearly every developer at some point. I'm going to show you exactly what it is and how to fix it.

React Query Cache Invalidation Diagram

Let me walk you through the most common scenario. You've got a vehicle management app. A user updates a vehicle, and you want to refresh the list:

javascript
1const { mutate: updateVehicle } = useMutation({
2  mutationFn: ({ id, data }) => api.put(`/vehicles/${id}`, data),
3  onSuccess: () => {
4    // ❌ This doesn't work
5    queryClient.invalidateQueries({ queryKey: ['vehicles', 'list'] });
6  }
7});

The mutation executes. The server responds. The success callback fires. But the UI doesn't update.

Why? Because your query is defined with a different key structure. Maybe it's:

javascript
1const { data: vehicles } = useQuery({
2  queryKey: ['vehicles', { page: 1, sort: 'name' }],
3  queryFn: fetchVehicles
4});

You're trying to invalidate

text
['vehicles', 'list']
, but your actual query is
text
['vehicles', { page: 1, sort: 'name' }]
. They don't match exactly, so nothing happens.

The solution? Use

text
exact: false
:

javascript
1onSuccess: () => {
2  // ✅ This works
3  queryClient.invalidateQueries({ 
4    queryKey: ['vehicles', 'list'], 
5    exact: false 
6  });
7}

Or better yet, match the actual query key structure:

javascript
1onSuccess: () => {
2  queryClient.invalidateQueries({ 
3    queryKey: ['vehicles'] // This will match ALL vehicles queries
4  });
5}

[INSERT VISUAL: Code comparison - "❌ DOESN'T MATCH" vs "✅ MATCHES" showing different query key structures]

Understanding
text
exact: true
vs
text
exact: false

React Query gives you two modes for invalidation, and this is critical to understand.

text
exact: true
(the default): Only invalidates queries with exactly matching keys.

javascript
1queryClient.invalidateQueries({
2  queryKey: ['todos'],
3  exact: true
4});
5
6// ✅ This query WILL be invalidated
7const query1 = useQuery({
8  queryKey: ['todos'],
9  queryFn: fetchTodos
10});
11
12// ❌ This query WILL NOT be invalidated
13const query2 = useQuery({
14  queryKey: ['todos', { page: 1 }],
15  queryFn: fetchTodos
16});

The second query doesn't match exactly because it has extra parameters in the key.

text
exact: false
(or just omit the queryKey): Invalidates queries that start with the specified key prefix.

javascript
1queryClient.invalidateQueries({
2  queryKey: ['todos'],
3  exact: false  // or just omit this, exact: false is default
4});
5
6// ✅ This query WILL be invalidated
7const query1 = useQuery({
8  queryKey: ['todos'],
9  queryFn: fetchTodos
10});
11
12// ✅ THIS WILL ALSO BE INVALIDATED
13const query2 = useQuery({
14  queryKey: ['todos', { page: 1 }],
15  queryFn: fetchTodos
16});
17
18// ✅ THIS TOO
19const query3 = useQuery({
20  queryKey: ['todos', 23, 'details'],
21  queryFn: fetchTodoDetails
22});

Here's the practical rule: If you're invalidating after a mutation that affects multiple related queries, use

text
exact: false
or just specify the parent key.

The Mutation + Invalidation Pattern

This is the bread-and-butter pattern for keeping your React Query cache in sync with the server. After a mutation succeeds, you tell React Query to mark certain queries as stale, which triggers automatic refetches.

javascript
1import { useMutation, useQueryClient } from '@tanstack/react-query';
2
3function CreateUserComponent() {
4  const queryClient = useQueryClient();
5  
6  const mutation = useMutation({
7    mutationFn: async (newUser) => {
8      const res = await api.post('/users', newUser);
9      return res.data;
10    },
11    onSuccess: () => {
12      // When mutation succeeds, refetch the users list
13      queryClient.invalidateQueries({ queryKey: ['users'] });
14    }
15  });
16
17  const handleSubmit = () => {
18    mutation.mutate({ name: 'Alice', email: 'alice@example.com' });
19  };
20
21  return (
22    <div>
23      <button onClick={handleSubmit} disabled={mutation.isPending}>
24        {mutation.isPending ? 'Adding...' : 'Add User'}
25      </button>
26      {mutation.isError && <p>Error: {mutation.error.message}</p>}
27    </div>
28  );
29}

Here's what happens:

  1. User clicks the button,
    text
    mutation.mutate()
    is called
  2. The POST request fires to
    text
    /users
  3. Server responds successfully
  4. text
    onSuccess
    callback executes
  5. text
    queryClient.invalidateQueries({ queryKey: ['users'] })
    marks all user queries as stale
  6. Any component using
    text
    useQuery({ queryKey: ['users'] })
    will automatically refetch in the background
  7. UI updates with fresh data

This is the power of React Query. You don't manually update state or manage loading spinners. The cache invalidation triggers the refetch, and React Query handles the rest.

[INSERT VISUAL: Timeline showing "User Clicks" → "Mutation Fires" → "Server Responds" → "onSuccess Fires" → "invalidateQueries Triggers" → "Automatic Refetch" → "UI Updates"]

Common Mistakes That Break Cache Invalidation

1. Typos in Query Keys

You define a query with

text
['todos']
but try to invalidate
text
['todos', 'list']
. React Query won't find a match.

javascript
1// ❌ Query defined here
2const { data } = useQuery({
3  queryKey: ['todos'],
4  queryFn: fetchTodos
5});
6
7// ❌ Trying to invalidate with a different key
8onSuccess: () => {
9  queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });
10}

Fix: Double-check your query key strings. Use constants to avoid typos:

javascript
1// queryKeys.ts
2export const queryKeys = {
3  todos: {
4    all: ['todos'],
5    list: ['todos', 'list'],
6    detail: (id) => ['todos', 'detail', id]
7  }
8};
9
10// In your components
11const { data } = useQuery({
12  queryKey: queryKeys.todos.all,
13  queryFn: fetchTodos
14});
15
16onSuccess: () => {
17  queryClient.invalidateQueries({ queryKey: queryKeys.todos.all });
18}

2. Forgetting the Difference Between
text
invalidateQueries
and
text
refetchQueries

text
invalidateQueries
marks queries as stale and refetches only the active ones (ones currently used by a component).

text
refetchQueries
refetches immediately, regardless of whether the query is active.

javascript
1// Invalidates active queries only (default)
2queryClient.invalidateQueries({ queryKey: ['users'] });
3
4// Refetches all matching queries, active or not
5queryClient.refetchQueries({ queryKey: ['users'] });

Usually,

text
invalidateQueries
is what you want. It's more efficient.

3. Not Accounting for Nested or Parameterized Query Keys

If your query has parameters (like pagination, filters, or IDs), your invalidation needs to account for that.

javascript
1// Query with parameters
2const { data } = useQuery({
3  queryKey: ['users', { page: 1, search: 'alice' }],
4  queryFn: () => fetchUsers({ page: 1, search: 'alice' })
5});
6
7// ❌ This won't match
8queryClient.invalidateQueries({ queryKey: ['users'] });
9
10// ✅ This will match (and any users query with those parameters)
11queryClient.invalidateQueries({ 
12  queryKey: ['users'], 
13  exact: false 
14});
15
16// ✅ Or be specific about which user query
17queryClient.invalidateQueries({ 
18  queryKey: ['users', { page: 1, search: 'alice' }],
19  exact: true 
20});

4. Invalidating in
text
onError
Instead of
text
onSettled

If you want to invalidate regardless of whether the mutation succeeded or failed, use

text
onSettled
. This is useful for recovering from errors—if something goes wrong, you might want to refetch to see the current server state.

javascript
1// ❌ Only invalidates on success
2onSuccess: () => {
3  queryClient.invalidateQueries({ queryKey: ['items'] });
4}
5
6// ✅ Invalidates whether it succeeds or fails
7onSettled: () => {
8  queryClient.invalidateQueries({ queryKey: ['items'] });
9}

How to Debug Cache Invalidation Issues

If cache invalidation isn't working, here's how to diagnose it.

Step 1: Check Your Query Keys in the Browser

Use

text
queryClient.getQueryData()
to inspect what's actually in the cache:

javascript
1const queryClient = useQueryClient();
2
3// In the browser console, get all todos queries
4const todosData = queryClient.getQueryData(['todos']);
5console.log('Current todos in cache:', todosData);
6
7// Get state of all queries
8const queries = queryClient.getQueryCache().getAll();
9console.log('All queries:', queries.map(q => ({ key: q.queryKey, stale: q.isStale() })));

This shows you exactly what query keys React Query is tracking. If your mutation is trying to invalidate a key that doesn't exist, you'll spot it immediately.

Step 2: Add Console Logs to Your Invalidation

javascript
1onSuccess: () => {
2  console.log('Mutation succeeded, about to invalidate queries');
3  queryClient.invalidateQueries({ queryKey: ['items'] }).then(() => {
4    console.log('Invalidation complete');
5  });
6}

If you see "Mutation succeeded" but not "Invalidation complete," something's wrong. If you see both, but the UI doesn't update, the problem is likely that no component is using that query.

Step 3: Verify Components Are Actually Subscribed

Make sure you have a component somewhere that's actually calling

text
useQuery
with that key:

javascript
1// This must exist somewhere for invalidation to trigger a refetch
2const { data: items } = useQuery({
3  queryKey: ['items'],
4  queryFn: fetchItems
5});

If no component is using the query, React Query won't refetch it (it'll just mark it as stale). This is by design—no need to fetch data nobody's looking at.

[INSERT VISUAL: Debugging flowchart - "Is query in cache?" → "Are components subscribed?" → "Is invalidation working?" with decision points]

Advanced: Predicate Functions for Granular Control

For complex applications with lots of queries, you can invalidate queries based on custom logic using a predicate function:

javascript
1queryClient.invalidateQueries({
2  predicate: (query) => {
3    // Only invalidate queries where the first key element is 'items'
4    // AND the query involves this specific user
5    return query.queryKey[0] === 'items' && query.queryKey[1] === userId;
6  }
7});

This gives you fine-grained control. You can invalidate "all post queries for this user" or "all queries containing the word 'invoice'" or any custom logic you need.

javascript
1// Invalidate all queries related to a specific resource
2queryClient.invalidateQueries({
3  predicate: (query) => {
4    return query.queryKey.some(key => key === 'user' || key === userId);
5  }
6});

The Optimistic Update + Invalidation Combo

For the smoothest UX, combine optimistic updates with invalidation. Show the user the change immediately, then sync with the server:

javascript
1const updateTodo = useMutation({
2  mutationFn: ({ id, completed }) => 
3    api.patch(`/todos/${id}`, { completed }),
4  
5  onMutate: async ({ id, completed }) => {
6    // Cancel any in-flight queries
7    await queryClient.cancelQueries({ queryKey: ['todos'] });
8    
9    // Snapshot old data
10    const previousTodos = queryClient.getQueryData(['todos']);
11    
12    // Optimistically update cache
13    queryClient.setQueryData(['todos'], (old) =>
14      old.map(todo => 
15        todo.id === id ? { ...todo, completed } : todo
16      )
17    );
18    
19    return { previousTodos };
20  },
21  
22  onError: (err, variables, context) => {
23    // If mutation fails, rollback to previous data
24    queryClient.setQueryData(['todos'], context.previousTodos);
25  },
26  
27  onSettled: () => {
28    // After success or error, refetch to ensure we're in sync
29    queryClient.invalidateQueries({ queryKey: ['todos'] });
30  }
31});

Here's the flow:

  1. User toggles a todo
  2. Before the request even sends, the UI updates instantly (
    text
    onMutate
    )
  3. Request goes to server
  4. If it fails, we roll back the optimistic change (
    text
    onError
    )
  5. After success or failure, we invalidate and refetch the actual server state (
    text
    onSettled
    )

Users see instant feedback, and you're guaranteed to be in sync with the server.

Stale Time, Cache Time, and Invalidation

Two settings affect how invalidateQueries works:

text
staleTime
: How long cached data is considered "fresh" (default: 0ms). When a query's staleTime expires, React Query marks it as stale.

text
gcTime
(formerly
text
cacheTime
): How long inactive queries stay in memory (default: 5 minutes). After this, React Query removes them entirely.

When you call

text
invalidateQueries
, you're manually marking queries as stale, overriding staleTime. This is why invalidation is so powerful—you don't have to wait for staleTime to expire.

javascript
1const { data: items } = useQuery({
2  queryKey: ['items'],
3  queryFn: fetchItems,
4  staleTime: 1000 * 60 * 5,  // Data fresh for 5 minutes
5  gcTime: 1000 * 60 * 10     // Keep in cache for 10 minutes
6});
7
8// Later, when you invalidate...
9queryClient.invalidateQueries({ queryKey: ['items'] });
10// ^ Ignores staleTime, marks as stale immediately, refetches if active

When to Use Each Invalidation Strategy

ScenarioUse This
After creating a new item (POST)Invalidate parent list query
After updating an item (PATCH)Invalidate detail + list query
After deleting an item (DELETE)Invalidate list + all affected queries
Server pushes real-time update (WebSocket)Predicate function for surgical invalidation
User navigates between pages
text
refetchOnMount: true
(no invalidation needed)
Complex state with many dependenciesPredicate function or custom invalidation service

Putting It All Together

Here's a complete, production-ready example:

javascript
1// queryKeys.ts
2export const queryKeys = {
3  vehicles: {
4    all: ['vehicles'],
5    list: (filters) => ['vehicles', 'list', filters],
6    detail: (id) => ['vehicles', id]
7  }
8};
9
10// useVehicles.ts
11import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
12import { queryKeys } from './queryKeys';
13
14export function useVehicles(filters) {
15  return useQuery({
16    queryKey: queryKeys.vehicles.list(filters),
17    queryFn: () => fetchVehicles(filters)
18  });
19}
20
21export function useVehicleDetail(id) {
22  return useQuery({
23    queryKey: queryKeys.vehicles.detail(id),
24    queryFn: () => fetchVehicle(id)
25  });
26}
27
28export function useUpdateVehicle() {
29  const queryClient = useQueryClient();
30  
31  return useMutation({
32    mutationFn: ({ id, data }) => api.put(`/vehicles/${id}`, data),
33    onSuccess: (updatedVehicle) => {
34      // Update the detail query with the new data
35      queryClient.setQueryData(
36        queryKeys.vehicles.detail(updatedVehicle.id),
37        updatedVehicle
38      );
39      
40      // Invalidate all vehicle lists (in case sorting/filtering changed)
41      queryClient.invalidateQueries({
42        queryKey: queryKeys.vehicles.list(),
43        exact: false
44      });
45    }
46  });
47}
48
49// In a component
50function VehicleEditor({ vehicleId }) {
51  const { data: vehicle } = useVehicleDetail(vehicleId);
52  const updateVehicle = useUpdateVehicle();
53  
54  const handleSave = (updatedData) => {
55    updateVehicle.mutate({ id: vehicleId, data: updatedData });
56  };
57  
58  return (
59    // UI here
60  );
61}

This pattern is scalable, maintainable, and covers all the common invalidation scenarios.


Bottom line: React Query cache invalidation seems like magic until you understand that it's just pattern matching between query keys. Define your queries with consistent key structures, invalidate with matching prefixes, and you'll never have stale data again. The

text
exact: false
parameter and predicate functions give you fine-grained control when you need it. Start simple (just invalidate the parent key), and graduate to more advanced patterns as your app grows.

Your users will thank you for the instant, reliable data updates.

Most People Asked

staleTime is how long data stays "fresh" before React Query marks it as stale (default 0ms). gcTime is how long inactive queries stay in memory before getting deleted (default 5 minutes).

Your query key structure doesn't match what you're trying to invalidate. Use exact: false or match the parent query key prefix instead of trying to invalidate an exact key that doesn't exist.

Use invalidateQueries to mark queries as stale and refetch only active ones (more efficient). Use refetchQueries only when you need to force all matching queries to refetch immediately regardless of activity status.

Yes, use a predicate function inside invalidateQueries to check each query and return true/false based on whatever logic you need, like matching specific resource types or user IDs.

React Query marks it as stale but doesn't refetch it since nobody's subscribing to it. When a component eventually uses that query, it will refetch automatically to get fresh data.

Tags:
reactreact-querytanstack-querycache-managementdata-fetching
← View all articles
M
ManickavasaganAuthor

CS student and builder writing about tech, startups, AI, and productivity. Built a SaaS that didn't ship — walked away with real product experience instead. Sharing everything learned along the way.