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 object
  • invalidateWithDependencies — domain + deps invalidation
  • invalidateKeys — fine-grained invalidation
  • invalidateDomain — single domain invalidation

Maintenance

Adding a new domain

  1. Add the definition in domains.ts using createQueryKeys:
export const myFeatureKeys = createQueryKeys('myFeature', {
  table: {
    queryKey: (options: unknown) => [options],
  },
  details: {
    queryKey: (id: string) => [id],
  },
});
  1. Add it to the queryKeys object in keys.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

  • replenishment vs replenishmentSets: Two separate root domains. replenishmentSets holds policy details and tables; replenishment holds policy catalog data.

© 2025 Tightly. All rights reserved.
Developed by the Tightly team.