Entity Mapping
Entity mapping connects backend entity names to their corresponding query keys. This enables dynamic cache invalidation when the server tells you which entities changed.
The Problem
When your backend returns affected entities (e.g., after a bulk operation), you need to invalidate the right caches:
// Server response
{
success: true,
affectedEntities: ['transactions', 'accounts', 'movements']
}
// How do you map these strings to query keys?
The Solution: Entity Map
Create a central mapping from entity names to query keys:
import { accountsQueryGroup } from 'src/features/accounts/queries';
import { transactionsQueryGroup } from 'src/features/transactions/queries';
import { movementsQueryGroup } from 'src/features/movements/queries';
import { remindersQueryGroup } from 'src/features/reminders/queries';
import { recurrencesQueryGroup } from 'src/features/recurrences/queries';
import { QueryKey } from 'src/queries';
export const ENTITY_TO_QUERY_KEY_MAP: Record<string, QueryKey<any>> = {
accounts: accountsQueryGroup.list.queryKey,
transactions: transactionsQueryGroup.list.queryKey,
movements: movementsQueryGroup.all.queryKey,
reminders: remindersQueryGroup.all.queryKey,
recurrences: recurrencesQueryGroup.all.queryKey,
// Add all entities your backend might reference
};
Helper Functions
Get Query Keys from Entity Names
export function getQueryKeysFromAffectedEntities(
affectedEntities: string[]
): QueryKey<any>[] {
return affectedEntities
.map((entity) => ENTITY_TO_QUERY_KEY_MAP[entity])
.filter(Boolean); // Remove undefined (unknown entities)
}
Invalidate Affected Entities
import { invalidateQueriesForKeys } from 'src/queries';
export function invalidateAffectedEntities(affectedEntities: string[]): void {
const keys = getQueryKeysFromAffectedEntities(affectedEntities);
invalidateQueriesForKeys(keys);
}
Usage Patterns
After Bulk Operations
export const useBulkDelete = () =>
useMutation({
mutationFn: bulkDeleteApi,
onSuccess: (response) => {
// Server tells us what was affected
invalidateAffectedEntities(response.affectedEntities);
},
});
In WebSocket Handlers
socket.on('entities_changed', (data: { entities: string[] }) => {
invalidateAffectedEntities(data.entities);
});
After Import Operations
export const useDataImport = () =>
useMutation({
mutationFn: importDataApi,
onSuccess: (response) => {
// Import affected multiple entity types
invalidateAffectedEntities(response.importedEntities);
toast.success(`Imported ${response.count} records`);
},
});
Advanced Mapping
With Detail Queries
For operations that affect specific items:
interface EntityReference {
entity: string;
id?: string;
}
export const ENTITY_TO_QUERY_GROUP_MAP = {
accounts: accountsQueryGroup,
transactions: transactionsQueryGroup,
// ...
};
export function getQueryKeysFromEntityReferences(
refs: EntityReference[]
): QueryKey<any>[] {
return refs.flatMap((ref) => {
const group = ENTITY_TO_QUERY_GROUP_MAP[ref.entity];
if (!group) return [];
const keys = [group.all.queryKey];
if (ref.id && 'detail' in group) {
keys.push(group.detail.queryKey(ref.id));
}
return keys;
});
}
Hierarchical Invalidation
Some entities have relationships that require cascade invalidation:
const ENTITY_DEPENDENCIES: Record<string, string[]> = {
accounts: ['transactions', 'movements', 'reminders'],
transactions: ['movements'],
categories: ['transactions'],
};
export function invalidateWithDependencies(entities: string[]): void {
const allEntities = new Set<string>();
// Add direct entities
entities.forEach((e) => allEntities.add(e));
// Add dependencies
entities.forEach((entity) => {
ENTITY_DEPENDENCIES[entity]?.forEach((dep) => allEntities.add(dep));
});
invalidateAffectedEntities([...allEntities]);
}
Real-World Example: Recalculation Service
When a background job recalculates data:
// Backend response
interface RecalculationResult {
taskId: string;
status: 'completed';
affectedEntities: string[];
affectedAccounts: string[];
}
// Frontend handler
export const useRecalculationComplete = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: checkRecalculationStatus,
onSuccess: (result: RecalculationResult) => {
// Invalidate all affected entity lists
invalidateAffectedEntities(result.affectedEntities);
// Also invalidate specific account details
result.affectedAccounts.forEach((accountId) => {
queryClient.invalidateQueries({
queryKey: [accountsQueryGroup.detail.queryKey(accountId)],
});
});
},
});
};
Integration with Server Events
Polling for Changes
const { data: changes } = useQuery({
queryKey: ['sync', 'changes'],
queryFn: fetchPendingChanges,
refetchInterval: 30000, // Poll every 30s
});
useEffect(() => {
if (changes?.affectedEntities?.length) {
invalidateAffectedEntities(changes.affectedEntities);
}
}, [changes]);
Server-Sent Events
useEffect(() => {
const eventSource = new EventSource('/api/events');
eventSource.addEventListener('cache_invalidate', (event) => {
const { entities } = JSON.parse(event.data);
invalidateAffectedEntities(entities);
});
return () => eventSource.close();
}, []);
Type Safety
Make the mapping type-safe:
// Define valid entity names
type EntityName =
| 'accounts'
| 'transactions'
| 'movements'
| 'reminders'
| 'recurrences'
| 'categories';
// Type-safe map
export const ENTITY_TO_QUERY_KEY_MAP: Record<EntityName, QueryKey<any>> = {
accounts: accountsQueryGroup.list.queryKey,
transactions: transactionsQueryGroup.list.queryKey,
movements: movementsQueryGroup.all.queryKey,
reminders: remindersQueryGroup.all.queryKey,
recurrences: recurrencesQueryGroup.all.queryKey,
categories: categoriesQueryGroup.list.queryKey,
};
// Type-safe function
export function invalidateAffectedEntities(
entities: EntityName[]
): void {
const keys = entities.map((e) => ENTITY_TO_QUERY_KEY_MAP[e]);
invalidateQueriesForKeys(keys);
}
Best Practices
1. Keep the Map Centralized
Put the entity map in a single file that imports all query groups:
src/
queries/
index.ts # Core Query Cache Flow utilities
entityMap.ts # Entity mapping (imports from features)
2. Handle Unknown Entities Gracefully
export function getQueryKeysFromAffectedEntities(
entities: string[]
): QueryKey<any>[] {
return entities
.map((entity) => {
const key = ENTITY_TO_QUERY_KEY_MAP[entity];
if (!key) {
console.warn(`Unknown entity in invalidation: ${entity}`);
}
return key;
})
.filter(Boolean);
}
3. Use all Keys for Broad Invalidation
When the server says "transactions changed" without specifics, use the all key:
// Invalidates ALL transaction queries (list, detail, filtered, etc.)
transactions: transactionsQueryGroup.all.queryKey
4. Document the Contract
Keep backend and frontend entity names synchronized:
/**
* Entity names must match the backend's entity identifiers.
* See: /backend/docs/entities.md
*
* @example
* Backend returns: { affectedEntities: ['accounts', 'transactions'] }
* Frontend maps to: [accountsQueryGroup.list.queryKey, ...]
*/
export const ENTITY_TO_QUERY_KEY_MAP = { ... };
Summary
Entity mapping provides:
- Dynamic invalidation - React to server-reported changes
- Decoupled code - Backend and frontend don't share cache logic
- Flexible integration - Works with REST, WebSockets, SSE, polling
- Type safety - Catch invalid entity names at compile time
This pattern is essential when your backend performs operations that affect multiple entity types (imports, exports, bulk operations, background jobs).