650 lines
15 KiB
Markdown
650 lines
15 KiB
Markdown
# Query Invalidation
|
|
|
|
Query invalidation is the process of marking queries as stale and potentially refetching them. This is essential for keeping your cache in sync with server state after mutations.
|
|
|
|
## Basic Invalidation
|
|
|
|
```tsx
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
|
|
function CreateTodo() {
|
|
const queryClient = useQueryClient();
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: (newTodo) => {
|
|
return fetch('/api/todos', {
|
|
method: 'POST',
|
|
body: JSON.stringify(newTodo),
|
|
}).then(res => res.json());
|
|
},
|
|
onSuccess: () => {
|
|
// Mark todos queries as stale and refetch
|
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
|
},
|
|
});
|
|
|
|
return (
|
|
<button onClick={() => mutation.mutate({ title: 'New Todo' })}>
|
|
Create Todo
|
|
</button>
|
|
);
|
|
}
|
|
```
|
|
|
|
## Invalidation Methods
|
|
|
|
### invalidateQueries
|
|
|
|
Marks queries as stale and triggers refetch of active queries:
|
|
|
|
```tsx
|
|
// Invalidate all queries
|
|
queryClient.invalidateQueries();
|
|
|
|
// Invalidate specific query
|
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
|
|
|
// Invalidate query with exact match
|
|
queryClient.invalidateQueries({ queryKey: ['todo', todoId], exact: true });
|
|
|
|
// Invalidate and wait for refetch
|
|
await queryClient.invalidateQueries({ queryKey: ['todos'] });
|
|
```
|
|
|
|
### refetchQueries
|
|
|
|
Directly refetch queries without marking as stale first:
|
|
|
|
```tsx
|
|
// Refetch all queries
|
|
queryClient.refetchQueries();
|
|
|
|
// Refetch specific queries
|
|
queryClient.refetchQueries({ queryKey: ['todos'] });
|
|
|
|
// Refetch only active queries
|
|
queryClient.refetchQueries({ queryKey: ['todos'], type: 'active' });
|
|
|
|
// Refetch only inactive queries
|
|
queryClient.refetchQueries({ queryKey: ['todos'], type: 'inactive' });
|
|
```
|
|
|
|
### resetQueries
|
|
|
|
Reset queries to their initial state:
|
|
|
|
```tsx
|
|
// Reset and refetch
|
|
queryClient.resetQueries({ queryKey: ['todos'] });
|
|
|
|
// Reset specific query
|
|
queryClient.resetQueries({ queryKey: ['todo', todoId] });
|
|
```
|
|
|
|
## Query Key Matching
|
|
|
|
### Prefix Matching
|
|
|
|
By default, invalidateQueries uses prefix matching:
|
|
|
|
```tsx
|
|
// This query
|
|
useQuery({ queryKey: ['todos', 'list', { page: 1 }], queryFn: fetchTodos });
|
|
|
|
// Is invalidated by any of these:
|
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
|
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });
|
|
queryClient.invalidateQueries({ queryKey: ['todos', 'list', { page: 1 }] });
|
|
|
|
// But NOT by these:
|
|
queryClient.invalidateQueries({ queryKey: ['todos', 'detail'] });
|
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
|
```
|
|
|
|
### Exact Matching
|
|
|
|
Use `exact: true` for precise matching:
|
|
|
|
```tsx
|
|
// Only invalidate this exact query key
|
|
queryClient.invalidateQueries({
|
|
queryKey: ['todos', 'list', { page: 1 }],
|
|
exact: true,
|
|
});
|
|
|
|
// This would invalidate:
|
|
useQuery({ queryKey: ['todos', 'list', { page: 1 }], ... });
|
|
|
|
// But NOT these:
|
|
useQuery({ queryKey: ['todos', 'list', { page: 2 }], ... });
|
|
useQuery({ queryKey: ['todos', 'list'], ... });
|
|
useQuery({ queryKey: ['todos'], ... });
|
|
```
|
|
|
|
### Predicate Functions
|
|
|
|
Use custom matching logic:
|
|
|
|
```tsx
|
|
// Invalidate all todos queries except detail queries
|
|
queryClient.invalidateQueries({
|
|
predicate: (query) => {
|
|
return query.queryKey[0] === 'todos' && query.queryKey[1] !== 'detail';
|
|
},
|
|
});
|
|
|
|
// Invalidate stale queries only
|
|
queryClient.invalidateQueries({
|
|
predicate: (query) => {
|
|
return query.state.isInvalidated;
|
|
},
|
|
});
|
|
|
|
// Invalidate based on query data
|
|
queryClient.invalidateQueries({
|
|
predicate: (query) => {
|
|
const data = query.state.data as Todo[] | undefined;
|
|
return data?.some((todo) => todo.userId === targetUserId) ?? false;
|
|
},
|
|
});
|
|
```
|
|
|
|
## Invalidation Timing
|
|
|
|
### Immediate Invalidation
|
|
|
|
Invalidate and refetch immediately:
|
|
|
|
```tsx
|
|
const mutation = useMutation({
|
|
mutationFn: createTodo,
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
|
},
|
|
});
|
|
```
|
|
|
|
### Delayed Invalidation
|
|
|
|
Wait for mutation to settle:
|
|
|
|
```tsx
|
|
const mutation = useMutation({
|
|
mutationFn: createTodo,
|
|
onSettled: () => {
|
|
// Runs after success or error
|
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
|
},
|
|
});
|
|
```
|
|
|
|
### Conditional Invalidation
|
|
|
|
Only invalidate under certain conditions:
|
|
|
|
```tsx
|
|
const mutation = useMutation({
|
|
mutationFn: updateTodo,
|
|
onSuccess: (data, variables) => {
|
|
if (data.isPublished) {
|
|
// Only invalidate if todo was published
|
|
queryClient.invalidateQueries({ queryKey: ['todos', 'published'] });
|
|
}
|
|
},
|
|
});
|
|
```
|
|
|
|
## Refetch Strategies
|
|
|
|
### Refetch Active Queries Only
|
|
|
|
```tsx
|
|
queryClient.invalidateQueries({
|
|
queryKey: ['todos'],
|
|
refetchType: 'active', // Only refetch active queries (default)
|
|
});
|
|
```
|
|
|
|
### Refetch All Queries
|
|
|
|
```tsx
|
|
queryClient.invalidateQueries({
|
|
queryKey: ['todos'],
|
|
refetchType: 'all', // Refetch both active and inactive queries
|
|
});
|
|
```
|
|
|
|
### Don't Refetch
|
|
|
|
```tsx
|
|
queryClient.invalidateQueries({
|
|
queryKey: ['todos'],
|
|
refetchType: 'none', // Only mark as stale, don't refetch
|
|
});
|
|
```
|
|
|
|
## Invalidation Patterns
|
|
|
|
### After Create
|
|
|
|
```tsx
|
|
const createTodo = useMutation({
|
|
mutationFn: (newTodo) => fetch('/api/todos', { method: 'POST', ... }),
|
|
onSuccess: () => {
|
|
// Invalidate list queries to show new item
|
|
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });
|
|
},
|
|
});
|
|
```
|
|
|
|
### After Update
|
|
|
|
```tsx
|
|
const updateTodo = useMutation({
|
|
mutationFn: ({ id, updates }) => fetch(`/api/todos/${id}`, { method: 'PATCH', ... }),
|
|
onSuccess: (data, { id }) => {
|
|
// Invalidate specific item
|
|
queryClient.invalidateQueries({ queryKey: ['todo', id] });
|
|
// Invalidate list in case item moved categories, etc.
|
|
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });
|
|
},
|
|
});
|
|
```
|
|
|
|
### After Delete
|
|
|
|
```tsx
|
|
const deleteTodo = useMutation({
|
|
mutationFn: (id) => fetch(`/api/todos/${id}`, { method: 'DELETE' }),
|
|
onSuccess: (_, id) => {
|
|
// Remove specific item from cache
|
|
queryClient.removeQueries({ queryKey: ['todo', id] });
|
|
// Invalidate lists
|
|
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });
|
|
},
|
|
});
|
|
```
|
|
|
|
### Bulk Operations
|
|
|
|
```tsx
|
|
const markAllDone = useMutation({
|
|
mutationFn: () => fetch('/api/todos/mark-all-done', { method: 'POST' }),
|
|
onSuccess: () => {
|
|
// Invalidate all todo-related queries
|
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
|
},
|
|
});
|
|
```
|
|
|
|
## Related Query Invalidation
|
|
|
|
### Update Multiple Related Queries
|
|
|
|
```tsx
|
|
const updateUser = useMutation({
|
|
mutationFn: ({ userId, updates }) => updateUserApi(userId, updates),
|
|
onSuccess: (data, { userId }) => {
|
|
// Invalidate user detail
|
|
queryClient.invalidateQueries({ queryKey: ['user', userId] });
|
|
// Invalidate user list
|
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
|
// Invalidate user's posts
|
|
queryClient.invalidateQueries({ queryKey: ['posts', 'user', userId] });
|
|
// Invalidate user's comments
|
|
queryClient.invalidateQueries({ queryKey: ['comments', 'user', userId] });
|
|
},
|
|
});
|
|
```
|
|
|
|
### Hierarchical Invalidation
|
|
|
|
```tsx
|
|
// Query key structure:
|
|
// ['todos'] - all todos
|
|
// ['todos', 'list'] - todo lists
|
|
// ['todos', 'list', filters] - filtered lists
|
|
// ['todos', 'detail'] - todo details
|
|
// ['todos', 'detail', id] - specific todo
|
|
|
|
const updateTodo = useMutation({
|
|
mutationFn: updateTodoApi,
|
|
onSuccess: (data, { id }) => {
|
|
// Invalidate specific todo detail
|
|
queryClient.invalidateQueries({ queryKey: ['todos', 'detail', id] });
|
|
// Invalidate all list queries (they might show this todo)
|
|
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });
|
|
},
|
|
});
|
|
```
|
|
|
|
## Invalidation with Infinite Queries
|
|
|
|
### Invalidate All Pages
|
|
|
|
```tsx
|
|
const createPost = useMutation({
|
|
mutationFn: (newPost) => createPostApi(newPost),
|
|
onSuccess: () => {
|
|
// Refetches all pages
|
|
queryClient.invalidateQueries({ queryKey: ['posts'] });
|
|
},
|
|
});
|
|
```
|
|
|
|
### Invalidate Specific Pages
|
|
|
|
```tsx
|
|
queryClient.invalidateQueries({
|
|
queryKey: ['posts'],
|
|
refetchPage: (page, index) => {
|
|
// Only refetch first page
|
|
return index === 0;
|
|
},
|
|
});
|
|
```
|
|
|
|
### Selective Page Refetch
|
|
|
|
```tsx
|
|
const updatePost = useMutation({
|
|
mutationFn: ({ id, updates }) => updatePostApi(id, updates),
|
|
onSuccess: (data, { id }) => {
|
|
queryClient.invalidateQueries({
|
|
queryKey: ['posts'],
|
|
refetchPage: (page, index) => {
|
|
// Only refetch pages containing this post
|
|
return page.posts.some((post) => post.id === id);
|
|
},
|
|
});
|
|
},
|
|
});
|
|
```
|
|
|
|
## Advanced Invalidation
|
|
|
|
### Cascading Invalidation
|
|
|
|
```tsx
|
|
const deleteProject = useMutation({
|
|
mutationFn: (projectId) => deleteProjectApi(projectId),
|
|
onSuccess: async (_, projectId) => {
|
|
// Step 1: Remove project from cache
|
|
queryClient.removeQueries({ queryKey: ['project', projectId] });
|
|
|
|
// Step 2: Invalidate project list
|
|
await queryClient.invalidateQueries({ queryKey: ['projects'] });
|
|
|
|
// Step 3: Invalidate related resources
|
|
await queryClient.invalidateQueries({ queryKey: ['tasks', 'project', projectId] });
|
|
await queryClient.invalidateQueries({ queryKey: ['members', 'project', projectId] });
|
|
|
|
// Step 4: Invalidate summary/stats
|
|
await queryClient.invalidateQueries({ queryKey: ['stats'] });
|
|
},
|
|
});
|
|
```
|
|
|
|
### Debounced Invalidation
|
|
|
|
For frequent updates, debounce invalidation:
|
|
|
|
```tsx
|
|
import { useDebouncedCallback } from 'use-debounce';
|
|
|
|
function SearchableList() {
|
|
const queryClient = useQueryClient();
|
|
|
|
const debouncedInvalidate = useDebouncedCallback(() => {
|
|
queryClient.invalidateQueries({ queryKey: ['search-results'] });
|
|
}, 500);
|
|
|
|
const updateFilters = (newFilters) => {
|
|
setFilters(newFilters);
|
|
debouncedInvalidate();
|
|
};
|
|
|
|
return <FilterPanel onChange={updateFilters} />;
|
|
}
|
|
```
|
|
|
|
### Throttled Invalidation
|
|
|
|
```tsx
|
|
import { throttle } from 'lodash';
|
|
|
|
const throttledInvalidate = throttle(() => {
|
|
queryClient.invalidateQueries({ queryKey: ['live-data'] });
|
|
}, 1000);
|
|
|
|
// In a websocket listener
|
|
socket.on('update', () => {
|
|
throttledInvalidate();
|
|
});
|
|
```
|
|
|
|
## Query Filters
|
|
|
|
Use query filters for more complex matching:
|
|
|
|
```tsx
|
|
import { QueryFilters } from '@tanstack/react-query';
|
|
|
|
const filters: QueryFilters = {
|
|
queryKey: ['todos'],
|
|
type: 'active', // 'active' | 'inactive' | 'all'
|
|
stale: true, // Only stale queries
|
|
exact: false, // Prefix matching
|
|
predicate: (query) => {
|
|
// Custom logic
|
|
return query.state.dataUpdatedAt > Date.now() - 60000;
|
|
},
|
|
};
|
|
|
|
queryClient.invalidateQueries(filters);
|
|
```
|
|
|
|
### Filter by State
|
|
|
|
```tsx
|
|
// Only invalidate stale queries
|
|
queryClient.invalidateQueries({
|
|
queryKey: ['todos'],
|
|
stale: true,
|
|
});
|
|
|
|
// Only invalidate fetching queries
|
|
queryClient.invalidateQueries({
|
|
predicate: (query) => query.state.fetchStatus === 'fetching',
|
|
});
|
|
```
|
|
|
|
### Filter by Type
|
|
|
|
```tsx
|
|
// Only active queries (currently mounted)
|
|
queryClient.invalidateQueries({
|
|
queryKey: ['todos'],
|
|
type: 'active',
|
|
});
|
|
|
|
// Only inactive queries (not mounted)
|
|
queryClient.invalidateQueries({
|
|
queryKey: ['todos'],
|
|
type: 'inactive',
|
|
});
|
|
|
|
// All queries
|
|
queryClient.invalidateQueries({
|
|
queryKey: ['todos'],
|
|
type: 'all',
|
|
});
|
|
```
|
|
|
|
## Performance Considerations
|
|
|
|
### Batch Invalidations
|
|
|
|
```tsx
|
|
// ❌ Multiple separate invalidations
|
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
|
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
|
|
|
// ✅ Batch with predicate
|
|
queryClient.invalidateQueries({
|
|
predicate: (query) => {
|
|
const key = query.queryKey[0];
|
|
return key === 'todos' || key === 'users' || key === 'projects';
|
|
},
|
|
});
|
|
```
|
|
|
|
### Smart Invalidation
|
|
|
|
Only invalidate what's needed:
|
|
|
|
```tsx
|
|
const updateTodo = useMutation({
|
|
mutationFn: updateTodoApi,
|
|
onSuccess: (data, { id, updates }) => {
|
|
// If only title changed, no need to invalidate lists
|
|
if (Object.keys(updates).length === 1 && 'title' in updates) {
|
|
queryClient.invalidateQueries({ queryKey: ['todo', id], exact: true });
|
|
} else {
|
|
// Status/category changed, invalidate lists too
|
|
queryClient.invalidateQueries({ queryKey: ['todo', id] });
|
|
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] });
|
|
}
|
|
},
|
|
});
|
|
```
|
|
|
|
### Prevent Over-Invalidation
|
|
|
|
```tsx
|
|
// ❌ Too broad - invalidates everything
|
|
queryClient.invalidateQueries();
|
|
|
|
// ❌ Still too broad - invalidates all todo queries
|
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
|
|
|
// ✅ Specific - only invalidates affected queries
|
|
queryClient.invalidateQueries({ queryKey: ['todos', 'list', filters] });
|
|
queryClient.invalidateQueries({ queryKey: ['todo', todoId] });
|
|
```
|
|
|
|
## Alternatives to Invalidation
|
|
|
|
Sometimes you don't need invalidation:
|
|
|
|
### Direct Cache Update
|
|
|
|
```tsx
|
|
const toggleTodo = useMutation({
|
|
mutationFn: (todoId) => toggleTodoApi(todoId),
|
|
onSuccess: (data, todoId) => {
|
|
// Directly update cache instead of invalidating
|
|
queryClient.setQueryData(['todo', todoId], data);
|
|
queryClient.setQueryData(['todos'], (old) =>
|
|
old?.map((todo) => (todo.id === todoId ? data : todo))
|
|
);
|
|
},
|
|
});
|
|
```
|
|
|
|
### Optimistic Updates
|
|
|
|
```tsx
|
|
const updateTodo = useMutation({
|
|
mutationFn: updateTodoApi,
|
|
onMutate: async ({ id, updates }) => {
|
|
await queryClient.cancelQueries({ queryKey: ['todos'] });
|
|
const previous = queryClient.getQueryData(['todos']);
|
|
|
|
// Update cache optimistically
|
|
queryClient.setQueryData(['todos'], (old) =>
|
|
old?.map((todo) => (todo.id === id ? { ...todo, ...updates } : todo))
|
|
);
|
|
|
|
return { previous };
|
|
},
|
|
onError: (err, vars, context) => {
|
|
queryClient.setQueryData(['todos'], context.previous);
|
|
},
|
|
// No need for invalidation if optimistic update is accurate
|
|
});
|
|
```
|
|
|
|
### Polling
|
|
|
|
```tsx
|
|
// Instead of manual invalidation, use automatic refetching
|
|
useQuery({
|
|
queryKey: ['live-data'],
|
|
queryFn: fetchLiveData,
|
|
refetchInterval: 5000, // Auto-refetch every 5 seconds
|
|
});
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
1. **Be Specific with Query Keys**
|
|
```tsx
|
|
// ✅ Good - specific invalidation
|
|
queryClient.invalidateQueries({ queryKey: ['todos', 'list', filters] });
|
|
|
|
// ❌ Bad - too broad
|
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
|
```
|
|
|
|
2. **Use Exact Matching When Appropriate**
|
|
```tsx
|
|
queryClient.invalidateQueries({
|
|
queryKey: ['todo', todoId],
|
|
exact: true, // Only this specific todo
|
|
});
|
|
```
|
|
|
|
3. **Invalidate in onSuccess for Success-Only**
|
|
```tsx
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
|
}
|
|
```
|
|
|
|
4. **Invalidate in onSettled for Always**
|
|
```tsx
|
|
onSettled: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
|
}
|
|
```
|
|
|
|
5. **Consider Alternatives**
|
|
- Direct cache updates for simple changes
|
|
- Optimistic updates for better UX
|
|
- Polling for real-time data
|
|
|
|
6. **Batch Related Invalidations**
|
|
```tsx
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ['todos'] }),
|
|
queryClient.invalidateQueries({ queryKey: ['stats'] }),
|
|
]);
|
|
```
|
|
|
|
7. **Use Predicate Functions for Complex Logic**
|
|
```tsx
|
|
queryClient.invalidateQueries({
|
|
predicate: (query) => {
|
|
// Custom matching logic
|
|
return shouldInvalidate(query);
|
|
},
|
|
});
|
|
```
|
|
|
|
8. **Monitor Invalidation Performance**
|
|
- Use React Query DevTools
|
|
- Check for unnecessary refetches
|
|
- Optimize query key structure
|