Cascade Invalidation Pattern
When deleting or updating an entity that has relationships with other entities, you need to invalidate not just the entity itself, but all related queries. This is cascade invalidation.
The Problem
In complex applications, entities are interconnected:
- Deleting an account affects transactions, balances, movements, recurrences, and reminders
- Updating a transaction affects account balances and movement aggregations
- Changing a user's role affects permissions, dashboards, and accessible resources
Manual invalidation is error-prone:
// Easy to forget related queries
queryClient.invalidateQueries({ queryKey: [accountsQueryGroup.detail.queryKey(id)] });
queryClient.invalidateQueries({ queryKey: [accountsQueryGroup.list.queryKey] });
// Forgot transactions! Forgot balances! Stale data everywhere!
The Solution
Define cascade invalidation logic in query group definitions, then use invalidateQueriesForKeys to execute them:
export const accountsQueryGroup = {
...accountsQueryGroupCRUD,
remove: {
...accountsQueryGroupCRUD.remove,
invalidates: (id: Account['id']) => [
// Base invalidations (from CRUD factory)
...accountsQueryGroupCRUD.remove.invalidates(id),
// Cascade to related entities
transactionsQueryGroup.all.queryKey,
recurrencesQueryGroup.all.queryKey,
transferencesQueryGroup.all.queryKey,
movementsQueryGroup.all.queryKey,
remindersQueryGroup.all.queryKey,
],
},
};
Real-World Example: Account Deletion
From a production application managing financial data:
Query Group Definition
import { movementsQueryGroup } from 'src/features/movements/queries';
import { recurrencesQueryGroup } from 'src/features/recurrences/queries';
import { remindersQueryGroup } from 'src/features/reminders/queries';
import { transactionsQueryGroup } from 'src/features/transactions/queries';
import { transferencesQueryGroup } from 'src/features/transferences/queries';
import { Account } from 'src/generated';
import { createQueryGroupCRUD, inyectKeysToQueries } from 'src/queries';
// Create base CRUD operations
let accountsQueryGroupCRUD = createQueryGroupCRUD<Account['id']>('accounts');
accountsQueryGroupCRUD = inyectKeysToQueries(accountsQueryGroupCRUD, { auth: true });
export const accountsQueryGroup = {
...accountsQueryGroupCRUD,
// Override remove with cascade invalidations
remove: {
...accountsQueryGroupCRUD.remove,
invalidates: (id: Account['id']) => [
// Base: invalidate the account itself and account list
...accountsQueryGroupCRUD.remove.invalidates(id),
// Cascade: all transactions belong to accounts
transactionsQueryGroup.all.queryKey,
// Cascade: recurrences are tied to accounts
recurrencesQueryGroup.all.queryKey,
// Cascade: transfers involve accounts
transferencesQueryGroup.all.queryKey,
// Cascade: movements aggregate account data
movementsQueryGroup.all.queryKey,
// Cascade: reminders are account-specific
remindersQueryGroup.all.queryKey,
],
},
};
Wrapper Hook
import { invalidateQueriesForKeys } from 'src/queries';
import { accountsQueryGroup } from './index';
export const useAccountDelete = ({ onSuccess, ...rest }) =>
generatedAccountDelete({
mutation: {
mutationKey: [accountsQueryGroup.remove.queryKey],
onSuccess: (data, variables, context) => {
// Normalize (remove from cache)
accountsQueryGroup.remove.normalize?.(data);
// Cascade invalidation - one line handles everything
invalidateQueriesForKeys(accountsQueryGroup.remove.invalidates(variables.id));
onSuccess?.(data, variables, context);
},
...rest,
},
});
Determining What to Invalidate
1. Direct Relationships
Invalidate queries for entities that directly reference the deleted entity:
// Transaction belongs to Account
// -> Delete Account invalidates Transactions
remove: {
invalidates: (id) => [
...baseInvalidates,
transactionsQueryGroup.all.queryKey,
],
}
2. Aggregations and Computed Data
Invalidate queries that aggregate or compute from the entity:
// Balances are computed from Accounts
// -> Delete Account invalidates Balances
remove: {
invalidates: (id) => [
...baseInvalidates,
balancesQueryGroup.list.queryKey,
dashboardQueryGroup.summary.queryKey,
],
}
3. Many-to-Many Relationships
Invalidate both sides of a many-to-many relationship:
// User has many Projects, Project has many Users
// -> Remove User from Project invalidates both
removeUserFromProject: {
invalidates: (projectId, userId) => [
projectsQueryGroup.detail.queryKey(projectId),
projectsQueryGroup.list.queryKey,
usersQueryGroup.detail.queryKey(userId),
usersQueryGroup.list.queryKey,
],
}
4. Nested Resources
Invalidate parent and child resources:
// Comments belong to Posts belong to Users
// -> Delete Post invalidates Comments and User's post list
remove: {
invalidates: (postId) => [
...baseInvalidates,
commentsQueryGroup.all.queryKey, // Child
usersQueryGroup.posts.queryKey(userId), // Parent collection
],
}
Patterns by Mutation Type
Create: Selective Invalidation
Creating usually affects fewer queries:
create: {
queryKey: { entity: 'transactions', method: 'create' },
invalidates: [
{ entity: 'transactions', method: 'list' }, // New item appears in list
accountsQueryGroup.list.queryKey, // Account balance changes
balancesQueryGroup.summary.queryKey, // Summary includes new transaction
],
}
Update: Targeted Invalidation
Updates typically affect the entity, its list, and related aggregations:
update: {
queryKey: (id) => ({ entity: 'transactions', method: 'update', id }),
invalidates: (id) => [
{ entity: 'transactions', id }, // The transaction itself
{ entity: 'transactions', method: 'list' }, // List includes this transaction
accountsQueryGroup.list.queryKey, // Account balance might change
balancesQueryGroup.summary.queryKey, // Summary might change
],
}
Delete: Broad Invalidation
Deletes often require the most extensive invalidation:
remove: {
queryKey: (id) => ({ entity: 'accounts', method: 'remove', id }),
invalidates: (id) => [
{ entity: 'accounts', id }, // The account itself
{ entity: 'accounts', method: 'list' }, // Account list
transactionsQueryGroup.all.queryKey, // All transactions
recurrencesQueryGroup.all.queryKey, // All recurrences
transferencesQueryGroup.all.queryKey, // All transfers
movementsQueryGroup.all.queryKey, // All movements
remindersQueryGroup.all.queryKey, // All reminders
balancesQueryGroup.list.queryKey, // Balance summary
dashboardQueryGroup.summary.queryKey, // Dashboard stats
],
}
Granular vs. Broad Invalidation
Use .all for Broad Invalidation
When an entity is deleted, often safest to invalidate all related queries:
// Broad: invalidate ALL transactions (safe but might refetch more than needed)
transactionsQueryGroup.all.queryKey
// Equivalent to: { entity: 'transactions' }
// Matches: { entity: 'transactions', method: 'list' }
// { entity: 'transactions', method: 'detail', id: '123' }
// { entity: 'transactions', method: 'create' }
// etc.
Use .list for Targeted Invalidation
When only the list needs refreshing:
// Targeted: only invalidate the list query
transactionsQueryGroup.list.queryKey
// Matches only: { entity: 'transactions', method: 'list' }
Use .detail() for Specific Invalidation
When you know the specific resource:
// Specific: only invalidate this one transaction
transactionsQueryGroup.detail.queryKey(transactionId)
// Matches only: { entity: 'transactions', method: 'detail', id: transactionId }
Conditional Cascade Invalidation
Sometimes you need to invalidate different queries based on the mutation's data:
export const useTransactionUpdate = ({ onSuccess, ...rest }) =>
generatedTransactionUpdate({
mutation: {
onSuccess: (data, variables, context) => {
transactionsQueryGroup.update.normalize?.(data);
const keysToInvalidate = transactionsQueryGroup.update.invalidates(data.id);
// Conditional: only invalidate account if it changed
if (data.accountId !== variables.previousAccountId) {
keysToInvalidate.push(
accountsQueryGroup.detail.queryKey(data.accountId),
accountsQueryGroup.detail.queryKey(variables.previousAccountId)
);
}
// Conditional: only invalidate category if it changed
if (data.categoryId !== variables.previousCategoryId) {
keysToInvalidate.push(categoriesQueryGroup.list.queryKey);
}
invalidateQueriesForKeys(keysToInvalidate);
onSuccess?.(data, variables, context);
},
...rest,
},
});
Backend-Driven Invalidation
Some APIs return which entities were affected:
interface MutationResponse {
data: Transaction;
affectedEntities: string[]; // ['accounts', 'balances', 'movements']
}
// Map entity names to query keys
export const ENTITY_TO_QUERY_KEY_MAP: Record<string, QueryKey<any>> = {
accounts: accountsQueryGroup.list.queryKey,
transactions: transactionsQueryGroup.list.queryKey,
balances: balancesQueryGroup.list.queryKey,
movements: movementsQueryGroup.list.queryKey,
recurrences: recurrencesQueryGroup.list.queryKey,
};
export function getQueryKeysFromAffectedEntities(affectedEntities: string[]) {
return affectedEntities
.map((entity) => ENTITY_TO_QUERY_KEY_MAP[entity])
.filter(Boolean);
}
// Use in wrapper hook
export const useComplexMutation = ({ onSuccess, ...rest }) =>
generatedComplexMutation({
mutation: {
onSuccess: (response: MutationResponse, variables, context) => {
// Base invalidations
const keysToInvalidate = baseQueryGroup.update.invalidates(response.data.id);
// Add backend-provided invalidations
const backendKeys = getQueryKeysFromAffectedEntities(response.affectedEntities);
keysToInvalidate.push(...backendKeys);
invalidateQueriesForKeys(keysToInvalidate);
onSuccess?.(response, variables, context);
},
...rest,
},
});
Avoiding Over-Invalidation
Don't Invalidate Unrelated Queries
// BAD - Invalidates everything
remove: {
invalidates: (id) => [
accountsQueryGroup.all.queryKey,
transactionsQueryGroup.all.queryKey,
categoriesQueryGroup.all.queryKey, // Categories are unrelated!
usersQueryGroup.all.queryKey, // Users are unrelated!
],
}
// GOOD - Only invalidate related queries
remove: {
invalidates: (id) => [
accountsQueryGroup.all.queryKey,
transactionsQueryGroup.all.queryKey, // Transactions belong to accounts
],
}
Use Granular Keys When Possible
// Less efficient - invalidates all transactions
invalidateQueriesForKeys([transactionsQueryGroup.all.queryKey]);
// More efficient - only invalidates transactions for this account
invalidateQueriesForKeys([
{
entity: 'transactions',
method: 'list',
accountId: deletedAccountId,
},
]);
Documentation Pattern
Document why each query is invalidated:
export const accountsQueryGroup = {
...accountsQueryGroupCRUD,
remove: {
...accountsQueryGroupCRUD.remove,
invalidates: (id: Account['id']) => [
...accountsQueryGroupCRUD.remove.invalidates(id),
// Transactions: All transactions belong to accounts
transactionsQueryGroup.all.queryKey,
// Recurrences: Recurrences are tied to account budgets
recurrencesQueryGroup.all.queryKey,
// Transfers: Transfers involve source/destination accounts
transferencesQueryGroup.all.queryKey,
// Movements: Movements aggregate transaction data by account
movementsQueryGroup.all.queryKey,
// Reminders: Reminders are account-specific notifications
remindersQueryGroup.all.queryKey,
],
},
};
Testing Cascade Invalidation
Manual Testing Checklist
After implementing cascade invalidation:
- Delete an account
- Verify all related lists refresh:
- Account list shows account removed ✓
- Transaction list no longer shows account's transactions ✓
- Balance summary recalculates ✓
- Dashboard stats update ✓
- Check for stale data: Navigate through the app, no old data should appear
Automated Testing
import { renderHook, waitFor } from '@testing-library/react';
import { queryClient } from 'src/queries/client';
import { useAccountDelete, useTransactions, useBalances } from './hooks';
test('deleting account invalidates related queries', async () => {
const { result: deleteResult } = renderHook(() => useAccountDelete());
const { result: transactionsResult } = renderHook(() => useTransactions());
const { result: balancesResult } = renderHook(() => useBalances());
// Pre-populate caches
await waitFor(() => {
expect(transactionsResult.current.data).toBeDefined();
expect(balancesResult.current.data).toBeDefined();
});
// Spy on invalidateQueries
const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries');
// Delete account
deleteResult.current.mutate({ id: 'account-123' });
await waitFor(() => {
expect(deleteResult.current.isSuccess).toBe(true);
});
// Verify invalidations were called
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: [expect.objectContaining({ entity: 'transactions' })],
});
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: [expect.objectContaining({ entity: 'balances' })],
});
});
Best Practices
1. Define Invalidations in Query Groups
// GOOD - Centralized logic
export const accountsQueryGroup = {
remove: {
invalidates: (id) => [...allRelatedQueries],
},
};
// AVOID - Scattered logic
// (in multiple wrapper hooks, each might forget some invalidations)
2. Use Spread for Base Invalidations
Always include base invalidations from the CRUD factory:
// GOOD
invalidates: (id) => [
...accountsQueryGroupCRUD.remove.invalidates(id),
...customInvalidations,
]
// BAD - Forgot base invalidations!
invalidates: (id) => [
...customInvalidations,
]
3. Invalidate Broader Rather Than Miss
When in doubt, invalidate more:
// GOOD - Safe, might refetch a bit extra
transactionsQueryGroup.all.queryKey
// RISKY - Might miss some transaction queries
transactionsQueryGroup.list.queryKey
4. Comment Relationships
invalidates: (id) => [
...baseInvalidates,
transactionsQueryGroup.all.queryKey, // Transactions belong to accounts
balancesQueryGroup.list.queryKey, // Balances computed from accounts
]
See Also
- invalidateQueriesForKeys - Batch invalidation helper
- Query Groups - Understanding query group structure
- Wrapper Hooks - Integrating invalidation into hooks
- Entity Mapping - Backend-driven invalidation pattern