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
_basepackage: 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.viewidentifies 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
| situation | key location | reason |
|---|---|---|
| any API-backed query projection | _base/src/utils/queryKeys.ts, nested by domain | backend may emit or invalidate it later; identity is one shared contract. |
| HTTP reader, WebSocket event, worker-side invalidation share a resource | the same _base factory | all runtime boundaries need the same exact address. |
| a mutation changes several projections | each projection's own factory | the mutation lists precise affected keys, not a catch-all. |
| a route adds a new consumer projection | that projection's domain key factory | cache 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 package | belongs to | reason |
|---|---|---|
| zod wire schemas for the invalidation socket | the backend contracts beside other schemas | a wire protocol, not a key |
| lists of session-scoped roots to clear on logout | each website, beside its provider | a per-app policy with one consumer |
| a type proving every gateway read has a key | the backend, beside its broadcaster | a compile check only the routers run |
- test: every export is a
<app>Keysobject 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
- state the projection and every request dimension that changes its result.
- create or extend
_base's domain-nested key factory with named operations. - search for raw tuple equivalents and replace them in the same change.
- confirm readers, prefetchers, mutations, and pushed invalidations use the factory, not duplicate literals.
- 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 keyfunction 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 keyasync function completeWorkflow(id: string) {await api.completeWorkflow(id);// invalidate — developer remembers the key structure but gets it slightly wrongqueryClient.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 keyfunction TimelinePanel({ id }: Props) {const { data: timeline } = useQuery({queryKey: ["workflowStatus", id], // camelCase instead of kebabqueryFn: () => 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 factoryfunction 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 invalidationasync 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 modulefunction TimelinePanel({ id }: Props) {const { data: timeline } = useQuery({queryKey: queryKeys.workflowTimeline.view(id), // different view, different keyqueryFn: () => 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
_baseis the shared address for readers, mutations, and event invalidations. - key-shape changes happen once and ripple to all consumers; invalidation is precise and reliable.