Query keys
Every query produced by useQ / useSuspenseQ / useInfiniteQ lives at a key with a strict, predictable shape:
["api", method, resolvedPath, searchParams];That’s it. Four positional segments. Understanding this shape unlocks a lot of power, because TanStack Query’s invalidateQueries / getQueriesData filters match keys by prefix.
The four segments
Section titled “The four segments”1. "api" — namespace
Section titled “1. "api" — namespace”A constant. Lets you tell use-q queries apart from any other TanStack Query usage in the same QueryClient:
queryClient.invalidateQueries({ queryKey: ["api"] }); // every use-q query2. method — HTTP method
Section titled “2. method — HTTP method”Uppercase, matches the schema route’s method: "GET", "POST", etc. In practice only "GET" shows up as a cached query — mutations don’t get cache entries.
3. resolvedPath — path with params filled in
Section titled “3. resolvedPath — path with params filled in”The route’s path string with placeholders substituted. For example:
| Route | pathParams | Resolved path |
|---|---|---|
/facilities/{facilityId}/posts | { facilityId: "f1" } | /facilities/f1/posts |
/facilities/{facilityId}/posts/{postId} | { facilityId: "f1", postId: "p1" } | /facilities/f1/posts/p1 |
4. searchParams — query params object
Section titled “4. searchParams — query params object”The raw searchParams object, not the encoded query string. TanStack Query deep-compares objects structurally, so { search: "" } and { search: "" } match across renders.
undefined values are stripped before the key is built, so missing optional params don’t fragment the cache:
useQ("listPosts", { pathParams: { facilityId: "f1" } });// → ["api", "GET", "/facilities/f1/posts", {}]
useQ("listPosts", { pathParams: { facilityId: "f1" }, searchParams: { search: undefined },});// → ["api", "GET", "/facilities/f1/posts", {}]Why this shape?
Section titled “Why this shape?”Hierarchical invalidation
Section titled “Hierarchical invalidation”Because keys are positional arrays, you can invalidate at any granularity:
// Everything use-qqueryClient.invalidateQueries({ queryKey: ["api"] });
// Every GETqueryClient.invalidateQueries({ queryKey: ["api", "GET"] });
// Every listPosts for any facilityqueryClient.invalidateQueries({ queryKey: ["api", "GET", "/facilities"], exact: false,});
// Only listPosts for facility f1, any searchParamsqueryClient.invalidateQueries({ queryKey: ["api", "GET", "/facilities/f1/posts"], exact: false,});
// Only listPosts for facility f1, search === ""queryClient.invalidateQueries({ queryKey: ["api", "GET", "/facilities/f1/posts", { search: "" }], exact: true,});The same machinery powers useQClient.invalidate(route, params?) — prefix vs exact is just whether you pass params.
Stable + diff-friendly
Section titled “Stable + diff-friendly”Two queries with the same params produce the same key by structural equality. You don’t need to memoize anything in your component — TanStack Query already does the work.
useQ("listPosts", { pathParams: { facilityId: "f1" }, searchParams: { search: "" },});// Re-rendering 100 times yields one cache entry.Cheap to inspect in devtools
Section titled “Cheap to inspect in devtools”The @tanstack/react-query-devtools panel groups entries by key, so the shape above gives you a navigable tree: namespace → method → path → params.
Built-in queryKeys factory
Section titled “Built-in queryKeys factory”createApiClient returns a queryKeys factory that builds these keys for you. It’s the right thing to use when you call TanStack Query’s primitives manually:
import { api } from "@/api/client";
await api.queryClient.prefetchQuery({ queryKey: api.queryKeys.listPosts({ pathParams: { facilityId: "f1" }, searchParams: { search: "" }, }), queryFn: ({ signal }) => api.fetcher.fetch("listPosts", { pathParams: { facilityId: "f1" }, searchParams: { search: "" }, signal, }),});You can also produce a prefix:
api.queryKeys.listPosts({ pathParams: { facilityId: "f1" } });// ["api", "GET", "/facilities/f1/posts"]
api.queryKeys.listPosts({ pathParams: { facilityId: "f1" } }, { exact: false });// Same — the second arg is documentation only; TanStack Query reads `exact`// from `invalidateQueries`/`getQueriesData` options.Tags vs keys: when to use which
Section titled “Tags vs keys: when to use which”| Use case | Tool |
|---|---|
”Invalidate this exact getPost” | useQClient.invalidate("getPost", params) |
”Invalidate every getPost for facility f1” | useQClient.invalidate("getPost", { pathParams: { facilityId: "f1" } }) |
”Invalidate everything tagged { type: "post" }” | useQClient.invalidateTag({ type: "post" }) |
| ”After a mutation, refresh everything the schema says it touches” | Schema invalidatesTags — automatic in useM |
Tags are loose-coupling — multiple routes can share a tag, and you don’t need to know the consumer’s searchParams. Keys are tight-coupling — you target a specific cache slot.
In practice, prefer tags for cross-cutting refreshes (after writes) and keys for surgical edits (after explicit user actions).