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 Scope | Matches | Does 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:
- POST response arrives with
meta.signals - Matching queries transition from
freshtostale - Active queries refetch immediately
- 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
resonancemiddleware (no envelope = no signals) - Check browser console for
[Resonance Client]logs