Problem Context

Fetching data from a Client Component is mostly a solved problem now โ€” but only if you stop using useEffect + useState +fetch. That trio gives you no caching, no dedup, no retries, no background refetch, no stale-while-revalidate, and no automatic invalidation when a mutation lands. Every team eventually re-implements those by hand, badly.

TanStack Query (formerly React Query) is the standard solution. v5 (2024) changed the API meaningfully โ€” single object signature,isPending instead of isLoading, gcTime instead of cacheTime, and useSuspenseQueryas a first-class API for React 19 Suspense. This guide covers the patterns that survive in 2026 alongside Server Components.

๐Ÿค” Sound familiar?
  • You wrote useEffect + useState for every fetch and dealt with race conditions yourself
  • You triggered the same request from three components and watched it fire three times
  • After a mutation, you call refetchmanually because you don't trust your invalidation
  • You're still using isLoading and wondering why TypeScript flags it

This guide covers query keys, mutations, optimistic updates, and how to compose React Query with Server Components in 2026.

Concept Explanation

TanStack Query is a cache indexed by query keys. A query key is an array; identical keys share a cache entry, share fetches (dedup), and re-render together when the entry updates.


flowchart LR
    A["useQuery(['posts', id])"] --> B{"Cache hit?"}
    B -->|Fresh| C["Return cached data"]
    B -->|Stale| D["Return cached + refetch in bg"]
    B -->|Miss| E["Fetch, then cache"]
    F["useMutation(updatePost)"] --> G["Run mutationFn"]
    G --> H["onSuccess: invalidateQueries(['posts'])"]
    H --> I["Marks matching keys stale"]
    I --> D

    style B fill:#4f46e5,color:#fff,stroke:#4338ca
    style H fill:#059669,color:#fff,stroke:#047857

Two timer concepts trip everyone up:

  • staleTime โ€” how long data is considered fresh. While fresh, no automatic refetch on mount/focus/reconnect. Default: 0 (always stale).
  • gcTime (was cacheTime) โ€” how long an inactive entry survives in cache before garbage collection. Default: 5 minutes.

Implementation

Step 1: Set up the provider once

// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState } from 'react';

export function Providers({ children }: { children: React.ReactNode }) {
  // useState ensures the client is created ONCE per browser session
  const [client] = useState(() => new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60_000,          // treat data fresh for 60s
        gcTime: 5 * 60_000,         // garbage-collect after 5min inactive
        refetchOnWindowFocus: true, // bring data back when tab regains focus
        retry: 2,
      },
    },
  }));
  return (
    <QueryClientProvider client={client}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

Step 2: Queries โ€” keep keys hierarchical and serializable

'use client';
import { useQuery } from '@tanstack/react-query';

async function fetchPost(id: string): Promise<Post> {
  const res = await fetch(`/api/posts/${id}`);
  if (!res.ok) throw new Error('Failed');
  return res.json();
}

export function PostView({ id }: { id: string }) {
  const { data, isPending, error } = useQuery({
    queryKey: ['posts', id],     // hierarchical: ['posts'] โ†’ ['posts', id]
    queryFn: () => fetchPost(id),
    enabled: !!id,                // skip until id is truthy
  });

  if (isPending) return <Skeleton />;
  if (error) return <ErrorCard error={error} />;
  return <Article post={data} />;
}

Hierarchical keys let you invalidate by prefix: queryClient.invalidateQueries({ queryKey: ['posts'] }) nukes every post.

Step 3: useSuspenseQuery + Suspense (React 19)

'use client';
import { useSuspenseQuery } from '@tanstack/react-query';
import { Suspense } from 'react';

function PostInner({ id }: { id: string }) {
  // No isPending/isError โ€” Suspense boundary handles loading,
  // ErrorBoundary handles errors. data is non-nullable.
  const { data } = useSuspenseQuery({
    queryKey: ['posts', id],
    queryFn: () => fetchPost(id),
  });
  return <Article post={data} />;
}

export function PostView({ id }: { id: string }) {
  return (
    <Suspense fallback={<Skeleton />}>
      <PostInner id={id} />
    </Suspense>
  );
}

Step 4: Mutations with invalidation

'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';

export function usePublishPost() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (id: string) =>
      fetch(`/api/posts/${id}/publish`, { method: 'POST' }).then(r => r.json()),
    onSuccess: (_data, id) => {
      // Mark every matching query stale โ†’ background refetch
      qc.invalidateQueries({ queryKey: ['posts', id] });
      qc.invalidateQueries({ queryKey: ['posts', 'list'] });
    },
  });
}

Step 5: Optimistic updates

const qc = useQueryClient();
const mutation = useMutation({
  mutationFn: (newTitle: string) => updateTitle(id, newTitle),
  onMutate: async (newTitle) => {
    // 1) Cancel any in-flight refetches for this key
    await qc.cancelQueries({ queryKey: ['posts', id] });
    // 2) Snapshot for rollback
    const previous = qc.getQueryData<Post>(['posts', id]);
    // 3) Optimistically write the new value
    qc.setQueryData<Post>(['posts', id], (old) =>
      old ? { ...old, title: newTitle } : old,
    );
    return { previous };
  },
  onError: (_err, _vars, ctx) => {
    // 4) Rollback on failure
    if (ctx?.previous) qc.setQueryData(['posts', id], ctx.previous);
  },
  onSettled: () => {
    // 5) Refetch to sync with server truth
    qc.invalidateQueries({ queryKey: ['posts', id] });
  },
});

Step 6: Hydrate from a Server Component (Next.js 16)

// app/posts/[id]/page.tsx โ€” Server Component prefetches, Client renders
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import { PostView } from './post-view';

export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const qc = new QueryClient();
  await qc.prefetchQuery({
    queryKey: ['posts', id],
    queryFn: () => fetchPostFromDb(id),
  });
  return (
    <HydrationBoundary state={dehydrate(qc)}>
      <PostView id={id} />
    </HydrationBoundary>
  );
}

Pitfalls

1. Forgetting useState(() => new QueryClient()). Constructing the client at module scope shares cache across users on the server (SSR) and constructing it inside the component without useState creates a new client every render. Both are bugs.

2. Object as query key. Keys are compared by deep equality but are easier to reason about as primitive arrays. Prefer['posts', id, { sort: 'date' }] over deeply nested objects.

3. staleTime: 0 + refetchOnWindowFocus: true. Defaults that look helpful but cause refetch storms when users tab back. Set a sane staleTime per query type.

4. Using React Query for one-shot data the server already has. If a Server Component can fetch it directly and pass it as a prop, do that. React Query shines for client-driven, repeatedly-fetched, mutation-coupled data.

5. Forgetting to cancelQueries in optimistic onMutate. An in-flight refetch can land after your optimistic update and overwrite it. Always cancel first.

6. v5 mental model gaps. isLoading is now isPending (the initial load), and isFetching is any in-flight request including background refetch. cacheTime renamed to gcTime. Update your code, not just your tsconfig.

Practical Takeaways

  • One QueryClient per browser session, created via useState(() => new QueryClient()).
  • Hierarchical query keys + prefix invalidation is the cleanest cache shape.
  • Set staleTime per resource โ€” 0 is rarely right.
  • Use useSuspenseQuery + <Suspense> + ErrorBoundary in React 19 โ€” eliminates branching on isPending/error at every call site.
  • Optimistic updates: cancel โ†’ snapshot โ†’ write โ†’ rollback on error โ†’ refetch on settled.
  • Prefetch in a Server Component and hydrate via HydrationBoundary for instant-loading Client widgets.
  • Install ReactQueryDevtools โ€” most query bugs are obvious in the panel.