TanStack Query (formerly React Query) is the closest thing to a default in React for server-state. It survived framework wars, RSC, and continued growing. This post is the working set.
The core idea
Server state is fundamentally different from UI state:
- It’s remote (you don’t own it).
- It’s async.
- It can be stale (someone else changed it).
- It needs invalidation.
TanStack Query owns the server state lifecycle. UI state belongs elsewhere.
Setup
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
retry: 2,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
</QueryClientProvider>
);
}
staleTime: 30s means data is considered fresh for 30s; no refetch in that window.
Queries
import { useQuery } from "@tanstack/react-query";
function UserProfile({ id }: { id: string }) {
const { data, isLoading, error } = useQuery({
queryKey: ["user", id],
queryFn: () => api.getUser(id),
});
if (isLoading) return <Skeleton />;
if (error) return <Error error={error} />;
return <Profile user={data} />;
}
queryKey is the cache key. Identical keys share cached data; different keys are separate.
Query keys discipline
Use object-style keys for clarity:
queryKey: ["users", "list", { page: 1, limit: 20, filter: "active" }]
queryKey: ["users", "detail", id]
queryKey: ["users", id, "posts"]
Hierarchical. Easy to invalidate sub-trees.
For type safety, the factory pattern :
const userKeys = {
all: ["users"] as const,
list: (filter: string) => [...userKeys.all, "list", filter] as const,
detail: (id: string) => [...userKeys.all, "detail", id] as const,
};
Centralized; refactor-friendly.
Mutations
import { useMutation, useQueryClient } from "@tanstack/react-query";
function CreatePost() {
const qc = useQueryClient();
const mutation = useMutation({
mutationFn: (data: NewPost) => api.createPost(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["posts"] });
},
});
return <button onClick={() => mutation.mutate({ title: "..." })}>Create</button>;
}
After mutation: invalidate dependent queries. They refetch.
Optimistic updates
const mutation = useMutation({
mutationFn: (newPost) => api.createPost(newPost),
// Optimistic
onMutate: async (newPost) => {
await qc.cancelQueries({ queryKey: ["posts"] });
const previous = qc.getQueryData(["posts"]);
qc.setQueryData(["posts"], (old) => [...old, { ...newPost, id: "temp" }]);
return { previous };
},
onError: (err, newPost, context) => {
qc.setQueryData(["posts"], context.previous);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: ["posts"] });
},
});
UI updates instantly; rolls back on error; reconciles on success.
Prefetching
// On hover
<Link
to={`/users/${id}`}
onMouseEnter={() => qc.prefetchQuery({
queryKey: userKeys.detail(id),
queryFn: () => api.getUser(id),
})}
>
Or in a route loader / RSC:
// Server-side prefetch
await qc.prefetchQuery({ queryKey: userKeys.detail(id), queryFn: ... });
const dehydrated = dehydrate(qc);
// Send dehydrated to client; rehydrate
Page navigation feels instant.
Pagination
const { data } = useQuery({
queryKey: ["posts", { page, limit: 20 }],
queryFn: () => api.getPosts(page, 20),
placeholderData: keepPreviousData,
});
keepPreviousData keeps the old data visible while new data loads. No flicker.
For infinite scroll: useInfiniteQuery:
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["posts"],
queryFn: ({ pageParam = 0 }) => api.getPosts(pageParam, 20),
getNextPageParam: (last, all) => last.next,
initialPageParam: 0,
});
Real-time
For WebSocket / SSE pushed data:
useEffect(() => {
const ws = new WebSocket(...);
ws.onmessage = (e) => {
const { type, data } = JSON.parse(e.data);
if (type === "post.created") {
qc.setQueryData(["posts"], (old) => [...old, data]);
}
};
return () => ws.close();
}, []);
Push updates into the cache directly; or invalidate to trigger refetch.
Suspense mode
const { data } = useSuspenseQuery({
queryKey: ["user", id],
queryFn: () => api.getUser(id),
});
// data is non-nullable; component suspends until ready
// Wrapped:
<Suspense fallback={<Skeleton />}>
<UserProfile id={id} />
</Suspense>
Cleaner than isLoading checks.
RSC integration
In Next.js App Router:
// app/users/[id]/page.tsx (Server Component)
async function UserPage({ params }) {
const qc = new QueryClient();
await qc.prefetchQuery({ queryKey: ["user", params.id], queryFn: () => fetchUser(params.id) });
return (
<HydrationBoundary state={dehydrate(qc)}>
<UserProfile id={params.id} /> {/* Client Component using useQuery */}
</HydrationBoundary>
);
}
Server fetches; client hydrates with that data; subsequent interactions use the cache.
DevTools
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
// in dev
<ReactQueryDevtools />
Shows every query, its state, when it last fetched. Indispensable while developing.
Common mistakes
1. queryKey as string
queryKey: `users-${id}` // BAD: hard to invalidate hierarchically
queryKey: ["users", id] // GOOD
2. Storing server state in useState
useState(initialFromServer) then keeping it in sync manually. TanStack Query is built for this.
3. Over-eager refetching
Default refetchOnWindowFocus: true can hammer your API. Tune staleTime per query.
4. Mutating without invalidation
UI shows stale data after a mutation. Always invalidate or set the cache directly.
5. Forgetting placeholderData
Pagination flickers between renders. keepPreviousData smooths it.
What I’d ship today
For React apps:
- TanStack Query for all server state.
- Zustand / Jotai / useState for UI state.
- Query key factory pattern.
- DevTools in dev.
- Suspense queries for cleaner async UX.
- RSC + hydration for first-paint perf.
Read this next
- React Server Components 2026
- Next.js App Router 2026
- TypeScript Monorepo 2026
- tRPC vs GraphQL vs REST 2026
If you want my TanStack Query patterns library, it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .