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.
- You wrote
useEffect+useStatefor 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
isLoadingand 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(wascacheTime) โ 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
QueryClientper browser session, created viauseState(() => new QueryClient()). - Hierarchical query keys + prefix invalidation is the cleanest cache shape.
- Set
staleTimeper resource โ 0 is rarely right. - Use
useSuspenseQuery+<Suspense>+ErrorBoundaryin React 19 โ eliminates branching onisPending/errorat every call site. - Optimistic updates: cancel โ snapshot โ write โ rollback on error โ refetch on settled.
- Prefetch in a Server Component and hydrate via
HydrationBoundaryfor instant-loading Client widgets. - Install ReactQueryDevtools โ most query bugs are obvious in the panel.

