Skip to content
ayoubb.dev/blog/keep-react-query-out-of-components

Why my components never call useQuery

Most React Query code I've inherited looks like this:

components/post-list.tsx
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.

LayerFileJob
Key registrycache/query-keys.tsEvery key in the app, in one object
Transportservices/api/posts.tsThe only code that makes HTTP requests
Hookshooks/api/use-posts.tsPairs a key with a fetcher and owns invalidation
Componentscomponents/posts/post-list.tsxCalls 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:

cache/query-keys.ts
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:

services/api/posts.ts
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:

hooks/api/use-posts.ts
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:

utils/types.ts
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

components/posts/post-list.tsx
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:

hooks/api/use-delete-post.ts
// 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:

components/posts/post-actions.tsx
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:

app/posts/page.tsx
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:

utils/get-query-client.ts
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:

  1. A hook's last parameter is options?, typed with QueryParamsOptions or MutationParamsOptions.
  2. The config spreads ...options: last for queries, before onSuccess for mutations.
  3. queryKey is a queryKeys.* call, never an inline array.
  4. Every mutation invalidates something or carries the no-invalidate marker.
  5. A mutation that defines onSuccess also calls options?.onSuccess?.().
  6. The // invalidates: comment matches the real calls, in both directions.
  7. No React Query hooks outside hooks/.

Rule 7 holds the rest up, and ESLint can check it without a custom script:

eslint.config.mjs
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 componentFour layers and a linter
Keys written in every fileOne registry where every key shares its domain's prefix
Invalidation guessedEvery write lists what it refreshes
The same fetch copied nine timesOne typed fetcher per endpoint
No SSR prefetchThe same key and fetcher on the server and the client
Tests mock fetchTests mock one hook
Review catches mistakes sometimesCI 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.