query-key ownership

why

  • per-caller key tuples diverge: two spellings of one projection = a cache miss for identical data.
  • invalidation without a canonical address: a broad key evicts everything, a near-miss key leaves stale data on screen.
  • a shared key factory makes the key the contract — readers, writers, and events share one address; key-shape changes happen once and ripple everywhere.

rule

  • a query key names one server projection plus its request scope — a shared invalidation address, not a convenient tuple.
  • every server-state key factory lives in the shared _base package: backend services, Workers, sockets, and frontend queries may all address the same projection.
  • every query hook, server prefetch, socket event, mutation, and cache update imports that factory directly.
  • specific enough that an event invalidates only the affected projection; stable enough that all consumers agree.

shape keys around semantic projections

queryKeys.processStatus.view(resourceId);
queryKeys.processTopology.read(resourceId, view, mode, runId);
  • not aliases for endpoint URLs: processStatus.view identifies the state the workflow interface renders; arguments identify the exact projection scope.
  • a different view of the same resource gets a different key when its fields, freshness, or invalidation facts differ.

ownership model

situationkey locationreason
any API-backed query projection_base/src/utils/queryKeys.ts, nested by domainbackend may emit or invalidate it later; identity is one shared contract.
HTTP reader, WebSocket event, worker-side invalidation share a resourcethe same _base factoryall runtime boundaries need the same exact address.
a mutation changes several projectionseach projection's own factorythe mutation lists precise affected keys, not a catch-all.
a route adds a new consumer projectionthat projection's domain key factorycache identity follows the contract, not a directory habit.

case study: one key from event to panel

  • canonical key: queryKeys.processStatus.view(resourceId).
  • frontend query hook reads its generated contract with it; backend event path publishes the same key on a relevant process change.
  • the socket receives the event and invalidates precisely the key the panel observes.
  • no caller reconstructs ['process-status', 'view', resourceId] — that prevents a near-identical key leaving stale output on screen.

the key package holds factories only

  • a query key factory "is just a simple object with entries and functions that will produce query keys" (TkDodo, "Effective React Query Keys", tkdodo.eu, read 2026-10-09). The shared key package holds exactly that and nothing else (operator, 2026-10-09: "query keys package are only intended for tanstack query query keys functions, not all of this").
  • what is built on top of keys belongs to its one consumer, not the package:
found in the key packagebelongs toreason
zod wire schemas for the invalidation socketthe backend contracts beside other schemasa wire protocol, not a key
lists of session-scoped roots to clear on logouteach website, beside its providera per-app policy with one consumer
a type proving every gateway read has a keythe backend, beside its broadcastera compile check only the routers run
  • test: every export is a <app>Keys object whose entries return a key; the package has no runtime dependency. After the 2026-10-09 cleanup, the reference workspace's key package holds exactly one keys file per app.

review and verification

  1. state the projection and every request dimension that changes its result.
  2. create or extend _base's domain-nested key factory with named operations.
  3. search for raw tuple equivalents and replace them in the same change.
  4. confirm readers, prefetchers, mutations, and pushed invalidations use the factory, not duplicate literals.
  5. exercise the event or mutation path; verify only the intended query refreshes.

do not

  • put API-backed query keys beside individual frontend hooks.
  • create a second key for the same projection because a new caller is nearby.
  • reuse one broad entity key for projections with different fields or freshness.
  • hand-write raw key arrays at callers.
  • invalidate the whole cache because the precise projection was never named.

good and bad

bad

// workflow panel creates its own key
function WorkflowPanel({ id }: Props) {
const { data: status } = useQuery({
queryKey: ["workflow-status", id],
queryFn: () => readWorkflowStatus(id),
});
return <div>{status}</div>;
}
// separate mutation component creates a similar-looking key
async function completeWorkflow(id: string) {
await api.completeWorkflow(id);
// invalidate — developer remembers the key structure but gets it slightly wrong
queryClient.invalidateQueries({
queryKey: ["workflow_status", id], // typo: underscore instead of hyphen
});
}
// result: the mutation runs, the data changes on the backend,
// but the panel's cache isn't invalidated (different key: underscore vs hyphen)
// user sees stale status on screen until they refresh
// separately, a timeline component creates yet another key
function TimelinePanel({ id }: Props) {
const { data: timeline } = useQuery({
queryKey: ["workflowStatus", id], // camelCase instead of kebab
queryFn: () => readWorkflowTimeline(id),
});
return <div>{timeline}</div>;
}
// now there are three "identical" keys with three different spellings:
// ["workflow-status", id] (panel)
// ["workflow_status", id] (mutation)
// ["workflowStatus", id] (timeline)
// cache misses, stale data, and hard-to-debug sync issues
  • each caller invents its own key structure.
  • a typo in the mutation's invalidation leaves the panel's cache stale — old data on screen.
  • a camelCase third caller creates a cache miss for data already in memory.

good

// _base/src/utils/queryKeys.ts (single source of truth)
export const queryKeys = {
workflowStatus: {
all: () => ["workflow-status"] as const,
view: (id: string) => [...queryKeys.workflowStatus.all(), "view", id] as const,
},
};
// workflow panel uses the factory
function WorkflowPanel({ id }: Props) {
const { data: status } = useQuery({
queryKey: queryKeys.workflowStatus.view(id),
queryFn: () => readWorkflowStatus(id),
});
return <div>{status}</div>;
}
// mutation uses the same factory for invalidation
async function completeWorkflow(id: string) {
await api.completeWorkflow(id);
queryClient.invalidateQueries({
queryKey: queryKeys.workflowStatus.view(id), // same factory, guaranteed match
});
}
// timeline projection owns its own key from the same factory module
function TimelinePanel({ id }: Props) {
const { data: timeline } = useQuery({
queryKey: queryKeys.workflowTimeline.view(id), // different view, different key
queryFn: () => readWorkflowTimeline(id),
});
return <div>{timeline}</div>;
}
// when mutation completes, it invalidates the exact key each projection owns
// each panel refreshes only when its own cache identity changes
  • one factory in _base is the shared address for readers, mutations, and event invalidations.
  • key-shape changes happen once and ripple to all consumers; invalidation is precise and reliable.

search pages

go to any page