Skip to main content

How Invalidation Works

When a backend mutation says invalidate('demo.tasks.index'), how does that become a React Query cache invalidation? URL-based query keys, route name conversion, and prefix matching.

Query Key Structure

Generated hooks use a [{ url: string }] query key format:

export const demoTasksIndexQueryKey = () =>
[{ url: '/demo/tasks' }] as const;

export const demoProfileShowQueryKey = () =>
[{ url: '/demo/profile' }] as const;

React Query uses these keys to index its cache. Same key = same cache entry.

Route Name to URL Conversion

The invalidation handler converts dot-notation route names to URL paths by dropping the last segment (the action) and joining the rest:

const routeNameToPath = (routeName: string): string => {
const parts = routeName.split('.');
parts.pop(); // Remove action segment (index, store, show, etc.)
return '/' + parts.join('/');
};

If the scope already starts with /, it's used as-is (direct URL matching).

The handler then invalidates all queries whose URL matches exactly or starts with the path followed by /:

queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey[0];
if (typeof key === 'object' && key !== null && 'url' in key) {
const url = (key as { url: string }).url;
return url === path || url.startsWith(path + '/');
}
if (typeof key === 'string') {
return key === scope;
}
return false;
},
});

Two match conditions: exact (/demo/tasks matches /demo/tasks) and prefix (/demo/tasks matches /demo/tasks/5). The trailing slash in startsWith(path + '/') prevents /demo/task from matching /demo/tasks.

The Matching Table

Invalidation ScopeMatchesDoes NOT Match
/demo/tasks/demo/tasks/demo/task
/demo/tasks/demo/tasks/5/demo/taskSettings
/demo/tasks/demo/tasks/5/comments/other/tasks
/users/users/user
/users/users/5/user/profile
/demo/posts/demo/posts/demo/post
/user/user/users

Multi-Scope Invalidation

A single mutation can invalidate multiple queries:

// Profile update affects both profile data and the auth user (navbar avatar)
return Resonance::flash('Profile updated')
->invalidate('demo.profile.show', 'user')
->response();
{
"type": "invalidate",
"scope": ["demo.profile.show", "user"]
}

Each scope invalidates matching queries independently. After the loop, router.invalidate() runs once to refresh TanStack Router loaders.

Manual Queries

Generated hooks get URL-based query keys automatically. Manual queries need the same format for invalidation to work:

// Matches invalidation for '/user'
const { data: user } = useQuery({
queryKey: [{ url: '/user' }],
queryFn: () => networkAdapter.fetch('/user').then(e => e.data),
});

// Would NOT match -- no url field
const { data: user } = useQuery({
queryKey: ['user'],
queryFn: () => networkAdapter.fetch('/user').then(e => e.data),
});

Debugging Invalidation

Open React Query Devtools and watch the cache after a mutation:

  1. POST response arrives with meta.signals
  2. Matching queries transition from fresh to stale
  3. Active queries refetch immediately
  4. Inactive queries refetch on next mount

If a query isn't invalidating:

  • Check the scope string matches the query's URL (inspect query key in devtools)
  • Check the route has the resonance middleware (no envelope = no signals)
  • Check browser console for [Resonance Client] logs