tanstack query
why
server state is cached state. improvised caching — fetches in effects, hand-built key tuples, polling by habit — produces competing copies of the same server fact with independent staleness. one query owner with one canonical key factory per server fact makes cache identity a declared contract: invalidation, prefetch, and optimistic updates converge on the same entry instead of diverging silently.
rule
TanStack Query owns persistent server-synchronized state. a query hook owns the
generated API call, schema validation, cache identity, refresh policy, and
mutation consequences. components render semantic states and invoke named
actions — they do not fetch, rebuild server state in useEffect, or decide cache
topology.
place cache identity beside its domain contract
- one canonical key factory per server fact, in the domain's query-keys file.
- hooks, server prefetch, socket invalidation, mutations, and optimistic updates all use that factory — never hand-written tuple lookalikes.
- divergent keys for the same fact (
"workflow-status"vs["workflow", "status"]vs["wf", id]) cannot invalidate each other: a socket event refreshes one hook and leaves the rest stale; an optimistic mutation updates one key; the page flips between stale and current. one factory makes every consumer ask the same identity question, so invalidation/mutation updates propagate everywhere.
| concern | owner | example |
|---|---|---|
| cache identity | domain query-key factory | queryKeys.processStatus.view(resourceId) |
| remote read and response validation | one query hook | useProcessStatus |
| real-time freshness signal | event/Socket boundary | invalidate the same canonical key |
| presentation | component | render loading, error, empty, and data states |
| domain change | mutation hook or service event | update or invalidate the smallest affected key family |
case study: live process status without polling
useProcessStatus calls the generated readActiveProcessStatus operation,
validates its shared response schema, and keys on
queryKeys.processStatus.view(resourceId). the process panel reads that query
only for the genuinely live accumulated output list; static navigation stays
server-composed, not a polling query. backend activity invalidates that same key
via socket — no polling, no second cache namespace: one projection, one read
path, one invalidation identity, one schema.
query design checklist
- name the server fact and create its canonical key factory before the hook.
- use the generated client operation; validate the response at the client boundary when the runtime can be untrusted.
- set
enabledfrom real request identity, not an arbitrary UI effect. - choose event invalidation, targeted update, or refetch from the source's actual freshness mechanism — no polling by habit.
- invalidate the narrowest affected key family after a mutation.
- keep cached server data out of UI contexts unless the context owns only ephemeral interaction state around it.
do not
- fetch inside a presentation component or mirror query data into local state.
- put raw array keys in multiple files for the same server fact.
- invalidate the whole cache after a local mutation.
- poll where the domain already publishes a trusted event or data is intentionally static for the page lifetime.
- treat query state as authorization, form draft, animation, or other client ephemeral state.
good and bad
bad
// ProcessPanel.tsx — invents its own keyconst { data: status } = useQuery({queryKey: ["workflow-status", workflowId],queryFn: () => fetchWorkflowStatus(workflowId),refetchInterval: 1_000, // polls every second});// ActivityHeader.tsx — invents a different key for the same factconst { data: workflowStatus } = useQuery({queryKey: ["wf", workflowId, "status"],queryFn: () => apiClient.workflow.readActiveWorkflowStatus({ workflowId }),});// socket event invalidates one key but not the othersocket.on("workflow.updated", (payload) => {queryClient.invalidateQueries({queryKey: ["workflow-status", workflowId], // misses ["wf", workflowId, "status"]});});
two keys for one fact: the socket refreshes the panel but not the header, the mutation leaves the header stale, and polling adds latency and server load.
good
// queryKeys.ts — canonical factoryexport const queryKeys = {workflowStatus: {view: (id: string) => ["workflow", "status", id],},};// ProcessPanel.tsxconst { data: status } = useQuery({queryKey: queryKeys.workflowStatus.view(workflowId),queryFn: () => apiClient.workflow.readActiveWorkflowStatus({ workflowId }),});// ActivityHeader.tsx — uses same factoryconst { data: workflowStatus } = useQuery({queryKey: queryKeys.workflowStatus.view(workflowId),queryFn: () => apiClient.workflow.readActiveWorkflowStatus({ workflowId }),});// socket event invalidates the canonical keysocket.on("workflow.updated", (payload) => {queryClient.invalidateQueries({queryKey: queryKeys.workflowStatus.view(payload.workflowId),});});
generated operation read by the hook, canonical key invalidated by events: all consumers see the fresh value at once — no polling, no stale splits.