Skip to content

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.

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 query

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:

RoutepathParamsResolved path
/facilities/{facilityId}/posts{ facilityId: "f1" }/facilities/f1/posts
/facilities/{facilityId}/posts/{postId}{ facilityId: "f1", postId: "p1" }/facilities/f1/posts/p1

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", {}]

Because keys are positional arrays, you can invalidate at any granularity:

// Everything use-q
queryClient.invalidateQueries({ queryKey: ["api"] });
// Every GET
queryClient.invalidateQueries({ queryKey: ["api", "GET"] });
// Every listPosts for any facility
queryClient.invalidateQueries({
queryKey: ["api", "GET", "/facilities"],
exact: false,
});
// Only listPosts for facility f1, any searchParams
queryClient.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.

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.

The @tanstack/react-query-devtools panel groups entries by key, so the shape above gives you a navigable tree: namespace → method → path → params.

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.
Use caseTool
”Invalidate this exact getPostuseQClient.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).