Why my components never call useQuery
Most React Query code I've inherited looks like this:
export function PostList({ tag, page }: { tag: string; page: number }) {
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ["posts", tag, page],
queryFn: async () => {
const res = await fetch(`/api/posts?tag=${tag}&page=${page}`);
return (await res.json()).data;
},
staleTime: 60_000,
});
const like = useMutation({
mutationFn: (id: number) => fetch(`/api/posts/${id}/like`, { method: "POST" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["posts", tag, page] });
queryClient.invalidateQueries({ queryKey: ["post"] });
},
});
if (isLoading) return <Spinner />;
// ...180 more lines of JSX
}It works, and it causes four kinds of bugs as the app grows:
- Keys drift. The next component that needs posts writes
["posts", { tag }], and the cache now holds two copies of the same data. One updates and the other doesn't. - Invalidation is a guess. Nobody knows what a write makes stale, so it refreshes too much
or too little. Here it refreshes
["post"], and nothing tells you whether any query uses that key. - The fetch is copied. The same URL building, error handling and unwrapping ends up in nine files, each slightly different.
- The server can't use it. The fetch lives inside a client component, so a server component can't prefetch it, and every page opens on a spinner.
None of this is React Query's fault. The component knows things it should only use: the key, the URL, the response shape and what each write affects.
Four layers, one direction
I split that knowledge into four layers. Each one only imports from the one above it.
| Layer | File | Job |
|---|---|---|
| Key registry | cache/query-keys.ts | Every key in the app, in one object |
| Transport | services/api/posts.ts | The only code that makes HTTP requests |
| Hooks | hooks/api/use-posts.ts | Pairs a key with a fetcher and owns invalidation |
| Components | components/posts/post-list.tsx | Calls hooks and renders |
A component can't get a key wrong because it never writes one.
Layer 1: one registry for every key
Every key comes from one object, and no key is ever written as an inline array:
export const queryKeys = {
posts: {
all: () => ["posts"] as const,
list: (params: PostListParams) =>
[...queryKeys.posts.all(), "list", params] as const,
infinite: (params: Omit<PostListParams, "page">) =>
[...queryKeys.posts.all(), "infinite", params] as const,
detail: (id: number) => [...queryKeys.posts.all(), "detail", id] as const,
},
comments: {
all: () => ["comments"] as const,
// ...same shape
},
};Every key spreads all(), so all() is a prefix of every post key. Invalidating
queryKeys.posts.all() can't miss one.
A domain can reference itself inside its own arrow functions, because they only run when they're called, after the object exists.
Layer 2: transport is the only code that knows HTTP
Each endpoint gets one typed function:
export async function getPosts(
params: PostListParams,
headers?: HeadersInit,
): Promise<PostList> {
const res = await fetch(apiUrl("/api/posts", params), { headers });
if (!res.ok) {
throw new Error(`Failed to fetch posts: ${res.status} ${res.statusText}`);
}
return (await res.json()).data;
}The optional headers parameter lets the same function run in the browser and in a server
component. On the server, you pass the incoming request's headers so cookies are forwarded.
It also throws on a bad response. React Query only marks a query as failed when the fetcher
rejects. If this returned null instead, a 500 would render as an empty list.
Layer 3: every hook has the same shape
Every data hook takes (args, options?). args holds what the request needs, and options
holds React Query settings:
export const usePosts = (
args: PostListParams,
options?: QueryParamsOptions<PostList>,
) =>
useQuery({
queryKey: queryKeys.posts.list(args),
queryFn: () => getPosts(args),
...options,
});The options type is where the rule is actually enforced:
export type QueryParamsOptions<T> = Omit<
UseQueryOptions<T, Error, T, readonly unknown[]>,
"queryKey" | "queryFn"
>;
export type MutationParamsOptions<TData, TVariables> = Omit<
UseMutationOptions<TData, Error, TVariables>,
"mutationFn"
>;Callers keep every setting, like enabled, select, refetchInterval and
placeholderData, except the two that would let them split the cache. ...options goes
last in a query, so a caller can override any default the hook sets.
I never write hooks like usePost(id, authorId, enabled). Once hooks take positional flags,
every one ends up with a different signature.
Layer 4: the component gets boring
export function PostList({ tag }: { tag: string }) {
const { data, isPending, isError } = usePosts({ tag });
if (isPending) return <PostListSkeleton />;
if (isError) return <ErrorState />;
return data.posts.map((post) => <PostCard key={post.id} post={post} />);
}It doesn't import anything from @tanstack/react-query. There's no key, no fetcher and no
staleTime to drift.
If another component needs the same posts, it calls usePosts({ tag }) too. Both calls build
the same key, so React Query sends one request and keeps one cache entry. Tests mock one hook
instead of fetch.
Mutations own their invalidation
The hook that writes the data knows what the write makes stale, so it does the invalidation:
// invalidates: posts, comments
export const useDeletePost = (
options?: MutationParamsOptions<void, { id: number }>,
) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deletePost,
...options,
onSuccess: (...args) => {
queryClient.invalidateQueries({ queryKey: queryKeys.posts.all() });
queryClient.invalidateQueries({ queryKey: queryKeys.comments.all() });
options?.onSuccess?.(...args);
},
});
};For mutations, the order flips. ...options goes before onSuccess, and the hook calls the
caller's onSuccess itself. If you spread ...options last, a caller that passes its own
onSuccess replaces yours and the invalidation silently stops happening. That bug only shows
up as stale data, usually in production.
The // invalidates: comment above the hook lists every domain the write refreshes. A
linter checks it in both directions: every domain in the comment needs a matching
invalidateQueries call, and every call needs to be in the comment. Writes that change
nothing cached, like exports, emails and downloads, carry a no-invalidate marker instead,
so skipping invalidation is always deliberate.
Most stale-data bugs come from adding a new read. You add a screen that shows comment counts, and the mutations that already change comments never learn about it. With the annotation, finding every mutation that touches comments is one grep.
Toasts belong at the call site
The hook doesn't know about UI. The component that calls it decides what to show:
const { mutateAsync } = useDeletePost({
onSuccess: () => router.push("/posts"),
});
const onDelete = () =>
toast.promise(mutateAsync({ id }), {
loading: "Deleting post…",
success: "Post deleted",
error: "Couldn't delete that post",
});The same mutation runs from a modal, a row menu and a bulk action bar, each with its own
message. And because the hook invalidates before it calls the caller's onSuccess, the posts
list is already refetching by the time router.push runs.
SSR prefetch with the same key and fetcher
This is where the layers pay off. A server component can prefetch using the same key and the same fetcher as the client hook:
export default async function PostsPage({ searchParams }: Props) {
const queryClient = getQueryClient();
const filters = await postsSearchParams.parse(searchParams);
const requestHeaders = await headers();
await queryClient.prefetchQuery({
queryKey: queryKeys.posts.list(filters),
queryFn: () => getPosts(filters, requestHeaders),
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PostList />
</HydrationBoundary>
);
}The server and the client agree on the key because there's only one way to build it. When
usePosts mounts, its data is already in the cache, so it doesn't send a request. The page
has data on first paint, without a spinner or a separate endpoint for the server.
Two hydration traps
The first is sharing a query client between requests on the server:
let browserQueryClient: QueryClient | undefined;
export function getQueryClient() {
// On the server, a new client per request. A shared one would serve
// one user's cached data to the next.
if (isServer) return makeQueryClient();
// In the browser, one client per tab.
browserQueryClient ??= makeQueryClient();
return browserQueryClient;
}The second is an empty state that lies:
const { data, isPending, isError } = usePosts({ tag });
// Wrong: shows "No posts yet" when the request failed.
if (data?.length === 0) return <p>No posts yet.</p>;
// Right: handle loading and errors first.
if (isPending || isError) return <Fallback />;prefetchQuery doesn't throw when the fetch fails, and dehydrate only includes successful
queries by default. A prefetch that failed on the server reaches the client as nothing, and
an unguarded empty state tells a user with 400 posts that they have none.
Make the linter enforce it
Conventions decay unless something checks them. These are the rules my hooks linter runs in CI:
- A hook's last parameter is
options?, typed withQueryParamsOptionsorMutationParamsOptions. - The config spreads
...options: last for queries, beforeonSuccessfor mutations. queryKeyis aqueryKeys.*call, never an inline array.- Every mutation invalidates something or carries the
no-invalidatemarker. - A mutation that defines
onSuccessalso callsoptions?.onSuccess?.(). - The
// invalidates:comment matches the real calls, in both directions. - No React Query hooks outside
hooks/.
Rule 7 holds the rest up, and ESLint can check it without a custom script:
export default [
{
files: ["components/**/*.{ts,tsx}", "app/**/*.{ts,tsx}"],
rules: {
"no-restricted-imports": [
"error",
{
paths: [
{
name: "@tanstack/react-query",
importNames: [
"useQuery",
"useSuspenseQuery",
"useInfiniteQuery",
"useMutation",
"useQueryClient",
],
message: "Call a hook from hooks/ instead.",
},
],
},
],
},
},
];Server components still import dehydrate and HydrationBoundary, which this allows.
What the structure buys
| Ad hoc, per component | Four layers and a linter |
|---|---|
| Keys written in every file | One registry where every key shares its domain's prefix |
| Invalidation guessed | Every write lists what it refreshes |
| The same fetch copied nine times | One typed fetcher per endpoint |
| No SSR prefetch | The same key and fetcher on the server and the client |
Tests mock fetch | Tests mock one hook |
| Review catches mistakes sometimes | CI catches them every time |
None of this uses anything unusual in React Query. It's the boring part: decide once where each kind of knowledge lives, then have a tool enforce it.