Skip to main content

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:

  1. Dynamic invalidation - React to server-reported changes
  2. Decoupled code - Backend and frontend don't share cache logic
  3. Flexible integration - Works with REST, WebSockets, SSE, polling
  4. 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).