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.
concernownerexample
cache identitydomain query-key factoryqueryKeys.processStatus.view(resourceId)
remote read and response validationone query hookuseProcessStatus
real-time freshness signalevent/Socket boundaryinvalidate the same canonical key
presentationcomponentrender loading, error, empty, and data states
domain changemutation hook or service eventupdate 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

  1. name the server fact and create its canonical key factory before the hook.
  2. use the generated client operation; validate the response at the client boundary when the runtime can be untrusted.
  3. set enabled from real request identity, not an arbitrary UI effect.
  4. choose event invalidation, targeted update, or refetch from the source's actual freshness mechanism — no polling by habit.
  5. invalidate the narrowest affected key family after a mutation.
  6. 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 key
const { 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 fact
const { data: workflowStatus } = useQuery({
queryKey: ["wf", workflowId, "status"],
queryFn: () => apiClient.workflow.readActiveWorkflowStatus({ workflowId }),
});
// socket event invalidates one key but not the other
socket.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 factory
export const queryKeys = {
workflowStatus: {
view: (id: string) => ["workflow", "status", id],
},
};
// ProcessPanel.tsx
const { data: status } = useQuery({
queryKey: queryKeys.workflowStatus.view(workflowId),
queryFn: () => apiClient.workflow.readActiveWorkflowStatus({ workflowId }),
});
// ActivityHeader.tsx — uses same factory
const { data: workflowStatus } = useQuery({
queryKey: queryKeys.workflowStatus.view(workflowId),
queryFn: () => apiClient.workflow.readActiveWorkflowStatus({ workflowId }),
});
// socket event invalidates the canonical key
socket.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.

search pages

go to any page