Query Key Factory
Centralized query key management with hierarchical scoping and dependency-based cache invalidation.
Problem
Before this module, query keys were scattered across 65+ composable files and mutations manually listed every domain to invalidate — some up to 15 invalidateQueries calls in a single onSuccess handler. Keys were hardcoded strings with no single source of truth.
Architecture
useQueryKeys/
├── createQueryKeys.ts # Factory helper (~50 lines)
├── domains.ts # All domain key definitions (source of truth)
├── index.ts # Barrel: assembles queryKeys object, re-exports everything
├── dependencies.ts # Cross-domain dependency graph (imports directly from domains.ts)
├── invalidation.ts # invalidateDomain, invalidateWithDependencies, invalidateKeys
└── README.md
domains.ts is the only file that imports createQueryKeys. It has no dependencies on the rest of the module, which means dependencies.ts can import domain keys from it directly without creating a circular dependency.
How it works
Query key hierarchy
Every domain is defined with createQueryKeys. The helper attaches a _def property at every nesting level — this is the key prefix used for broad cache invalidation.
There are two kinds of parameterized nodes:
queryKey nodes — the callable returns a plain key array, used directly in useQuery:
const suppliers = createQueryKeys('suppliers', {
table: {
queryKey: (options: unknown) => [options],
},
filters: null, // leaf node, no params
});
// suppliers.table(opts) → ['suppliers', 'table', opts]
// suppliers.table._def → ['suppliers', 'table']
entityKey nodes — the callable returns a sub-builder rooted at the entity prefix. This places the entity ID between the type name and its sub-keys, so a single prefix covers the entity and all its children:
const suppliers = createQueryKeys('suppliers', {
details: entityKey((supplierId: string) => [supplierId], {
catalog: {
queryKey: (options?: unknown) => [options],
},
}),
});
| Expression | Result |
|---|---|
suppliers._def | ['suppliers'] |
suppliers.details._def | ['suppliers', 'details'] |
suppliers.details('abc')._def | ['suppliers', 'details', 'abc'] |
suppliers.details('abc').catalog._def | ['suppliers', 'details', 'abc', 'catalog'] |
suppliers.details('abc').catalog(opts) | ['suppliers', 'details', 'abc', 'catalog', opts] |
suppliers.table._def | ['suppliers', 'table'] |
suppliers.table(opts) | ['suppliers', 'table', opts] |
suppliers.filters._def | ['suppliers', 'filters'] |
Key rule: _def gives you the prefix for invalidateQueries (matches all queries under that scope). For entity nodes, details(id)._def covers the entity and all its sub-keys for that specific ID — so a single invalidateKeys(queryClient, [suppliersKeys.details(id)]) invalidates the supplier detail and its entire catalog without listing them separately.
Dependency graph
dependencies.ts declares which queries should be invalidated when a given domain is mutated. Each entry is a QueryKeyDep — either a domain name (invalidates the whole domain) or a _def array (invalidates a specific sub-scope):
export const queryDependencies = {
// whole-domain deps
variant: ['basket', 'product', 'inventory', 'purchaseOrder', 'suppliers', 'budget', 'sales', 'dashboard'],
// mix of whole-domain + sub-scope deps
purchaseOrder: [
'suppliers',
variantKeys.information._def, // only ['variant', 'information'], not all of variant
variantKeys.inventory._def, // only ['variant', 'inventory']
'product',
'budget',
'sales',
'smartReplenishment',
inventoryKeys.table._def, // only ['inventory', 'table']
inventoryKeys.filters._def, // only ['inventory', 'filters']
'dashboard',
],
// ...
};
Dependencies are non-transitive. Invalidating variant cascades to basket, but does NOT cascade to basket’s own dependencies. This prevents runaway invalidation.
dependencies.ts imports domain keys directly from ./domains (not from ./keys) to avoid a circular dependency.
Invalidation helpers
// Invalidate one domain (all its queries)
invalidateDomain(queryClient, 'variant');
// Invalidate a domain + all its declared dependencies (1 line replaces 9-15)
invalidateWithDependencies(queryClient, 'variant');
// Fine-grained: invalidate specific scopes
invalidateKeys(queryClient, [queryKeys.purchaseOrder.table._def, queryKeys.inventory.filters._def]);
Usage
In a mutation (most common)
export function useUpdateVariant() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ variantId, variantUpdate }) => ApiService.variant.update(variantId, variantUpdate),
onSuccess: () => {
invalidateWithDependencies(queryClient, 'variant');
},
});
}
With setQueryData + invalidation
onSuccess: (response, args) => {
// Optimistically update the specific query
queryClient.setQueryData(queryKeys.purchaseOrder.details(args.id), () => response);
// Then invalidate everything else
invalidateWithDependencies(queryClient, 'purchaseOrder');
};
Using _def for scoped invalidation
// Invalidate only purchase order tables, not details
queryClient.invalidateQueries({ queryKey: queryKeys.purchaseOrder.table._def });
Using the callable form in useQuery
const query = useQuery({
queryKey: toRef(() => queryKeys.suppliers.details(toValue(supplierId))),
queryFn: () => ApiService.suppliers.getOne(toValue(supplierId)),
});
Auto-imports
All exports are auto-imported via the ./src/composables/** directory config in vite-plugins.ts. No manual imports needed:
queryKeys— the unified objectinvalidateWithDependencies— domain + deps invalidationinvalidateKeys— fine-grained invalidationinvalidateDomain— single domain invalidation
Maintenance
Adding a new domain
- Add the definition in
domains.tsusingcreateQueryKeys:
export const myFeatureKeys = createQueryKeys('myFeature', {
table: {
queryKey: (options: unknown) => [options],
},
details: {
queryKey: (id: string) => [id],
},
});
- Add it to the
queryKeysobject inkeys.ts:
export const queryKeys = {
// ...existing domains
myFeature: myFeatureKeys,
} as const;
Adding dependencies
Add an entry in dependencies.ts. Each dep can be a domain name string (whole domain) or a _def reference imported from ./domains (specific sub-scope):
import { inventoryKeys } from './domains';
export const queryDependencies = {
// ...existing
myFeature: [
'dashboard', // whole domain
inventoryKeys.table._def, // just ['inventory', 'table']
],
};
If other domains’ mutations should also invalidate your domain, add your domain to their existing arrays.
Node types in createQueryKeys
| Pattern | Meaning |
|---|---|
filters: null | Leaf node, no params. Only has _def. |
table: { queryKey: (opts) => [opts] } | Callable node. Has _def and is callable. Returns a plain frozen array. |
details: { queryKey: (id) => [id], catalog: { ... } } | Callable node with nested children. Children sit after the ID in the hierarchy (['details', id, 'catalog']). Avoid for entity keys. |
details: entityKey((id) => [id], { catalog: { ... } }) | Entity node. details._def scopes all entities. details(id) is directly usable as a queryKey and exposes children as properties (details(id).catalog._def). Children sit under the ID (['details', id, 'catalog']), so prefix-matching on details(id) covers the entity and all its sub-keys. |
Use entityKey whenever a node has both a runtime ID parameter and nested sub-keys that should be invalidated together as a group.
Backward compatibility
Existing use*QueryKey functions still work. They can be gradually updated to delegate to the factory:
// Before
export function useSupplierDetailsQueryKey(supplierId: MaybeRefOrGetter<string | null>) {
return ['suppliers', 'details', toValue(supplierId)];
}
// After — entity key is usable directly as a queryKey
export function useSupplierDetailsQueryKey(supplierId: MaybeRefOrGetter<string | null>) {
return queryKeys.suppliers.details(toValue(supplierId)!);
}
Known quirks
replenishmentvsreplenishmentSets: Two separate root domains.replenishmentSetsholds policy details and tables;replenishmentholds policy catalog data.