Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
npm install @tanstack/react-query// app/providers.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
refetchOnWindowFocus: false,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}// app/layout.tsx
import { QueryProvider } from "./providers";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<QueryProvider>{children}</QueryProvider>
</body>
</html>
);
}"use client";
import { useQuery } from "@tanstack/react-query";
function usePosts() {
return useQuery({
queryKey: ["posts"],
queryFn: async () => {
const res = await fetch("/api/posts");
if (!res.ok) throw new Error("Failed to fetch posts");
return res.json() as Promise<Post[]>;
},
});
}When to reach for this: You need client-side data fetching with automatic caching, background refetching, loading/error states, and cache invalidation after mutations.
// app/components/PostManager.tsx
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
interface Post {
id: number;
title: string;
body: string;
}
function usePosts() {
return useQuery({
queryKey: ["posts"],
queryFn: async (): Promise<Post[]> => {
const res = await fetch("https://jsonplaceholder.typicode.com/posts?_limit=10");
if (!res.ok) throw new Error("Failed to fetch");
return res.json();
},
});
}
function useCreatePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (newPost: { title: string; body: string }) => {
const res = await fetch("https://jsonplaceholder.typicode.com/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newPost),
});
return res.json() as Promise<Post>;
},
onMutate: async (newPost) => {
// Cancel in-flight queries
await queryClient.cancelQueries({ queryKey: ["posts"] });
// Snapshot previous value
const previousPosts = queryClient.getQueryData<Post[]>(["posts"]);
// Optimistically update
queryClient.setQueryData<Post[]>(["posts"], (old) => [
{ id: Date.now(), ...newPost } as Post,
...(old ?? []),
]);
return { previousPosts };
},
onError: (_err, _newPost, context) => {
// Rollback on error
queryClient.setQueryData(["posts"], context?.previousPosts);
},
onSettled: () => {
// Refetch to ensure server state
queryClient.invalidateQueries({ queryKey: ["posts"] });
},
});
}
export default function PostManager() {
const { data: posts, isLoading, error } = usePosts();
const createPost = useCreatePost();
if (isLoading) return <div className="p-6">Loading posts...</div>;
if (error) return <div className="p-6 text-red-600">Error: {error.message}</div>;
return (
<div className="max-w-2xl mx-auto p-6">
<form
onSubmit={(e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const title = (form.elements.namedItem("title") as HTMLInputElement).value;
const body = (form.elements.namedItem("body") as HTMLTextAreaElement).value;
createPost.mutate({ title, body });
form.reset();
}}
className="mb-6 space-y-3"
>
<input
name="title"
placeholder="Post title"
required
className="w-full border rounded px-3 py-2"
/>
<textarea
name="body"
placeholder="Post body"
required
rows={3}
className="w-full border rounded px-3 py-2"
/>
<button
type="submit"
disabled={createPost.isPending}
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
>
{createPost.isPending ? "Creating..." : "Create Post"}
</button>
</form>
<ul className="space-y-3">
{posts?.map((post) => (
<li key={post.id} className="border rounded p-3">
<h3 className="font-bold">{post.title}</h3>
<p className="text-gray-600 text-sm mt-1">{post.body}</p>
</li>
))}
</ul>
</div>
);
}What this demonstrates:
useQuery for data fetching with loading and error statesuseMutation with optimistic updates and rollback on errorinvalidateQueries after mutationsuseQueryClient to access the cache directlyqueryKey arrays; identical keys share cached datastaleTime controls how long data is considered fresh; within this window, no refetch occursgcTime (formerly cacheTime) controls how long inactive cache entries are kept (default 5 minutes)queryKey supports nested arrays and objects; keys are serialized and compared deeplyDependent queries (fetch B only after A completes):
function useUserPosts(userId: number | undefined) {
return useQuery({
queryKey: ["posts", userId],
queryFn: () => fetch(`/api/users/${userId}/posts`).then((r) => r.json()),
enabled: !!userId, // Only fetch when userId is available
});
}
function UserPosts() {
const { data: user } = useQuery({
queryKey: ["user"],
queryFn: () => fetch("/api/me").then((r) => r.json()),
});
const { data: posts } = useUserPosts(user?.id);
// posts query waits until user is loaded
}Infinite scroll / pagination:
import { useInfiniteQuery } from "@tanstack/react-query";
function useInfinitePosts() {
return useInfiniteQuery({
queryKey: ["posts", "infinite"],
queryFn: async ({ pageParam }) => {
const res = await fetch(`/api/posts?cursor=${pageParam}&limit=10`);
return res.json() as Promise<{
posts: Post[];
nextCursor: string | null;
}>;
},
initialPageParam: "",
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
}
function InfinitePostList() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfinitePosts();
const allPosts = data?.pages.flatMap((page) => page.posts) ?? [];
return (
<div>
{allPosts.map((post) => (
<div key={post.id}>{post.title}</div>
))}
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? "Loading more..." : "Load More"}
</button>
)}
</div>
);
}Prefetching for instant navigation:
function PostList() {
const queryClient = useQueryClient();
const { data: posts } = usePosts();
return (
<ul>
{posts?.map((post) => (
<li
key={post.id}
onMouseEnter={() => {
queryClient.prefetchQuery({
queryKey: ["post", post.id],
queryFn: () =>
fetch(`/api/posts/${post.id}`).then((r) => r.json()),
});
}}
>
<Link href={`/posts/${post.id}`}>{post.title}</Link>
</li>
))}
</ul>
);
}SSR hydration with Next.js App Router:
// app/posts/page.tsx (Server Component)
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from "@tanstack/react-query";
import PostList from "./PostList";
export default async function PostsPage() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ["posts"],
queryFn: async () => {
const res = await fetch("https://jsonplaceholder.typicode.com/posts?_limit=10");
return res.json();
},
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PostList />
</HydrationBoundary>
);
}useQuery infers the data type from queryFn return type; annotate the function for explicit typingqueryKey accepts readonly unknown[]; use as const for strict tuple typesuseMutation generics: useMutation<TData, TError, TVariables, TContext>select option transforms data and narrows the returned type// Explicit typing with select
const { data } = useQuery({
queryKey: ["posts"],
queryFn: async (): Promise<Post[]> => {
const res = await fetch("/api/posts");
return res.json();
},
select: (posts) => posts.filter((p) => p.id > 5), // data is Post[]
});
// Query key factory pattern
const postKeys = {
all: ["posts"] as const,
lists: () => [...postKeys.all, "list"] as const,
list: (filters: PostFilters) => [...postKeys.lists(), filters] as const,
details: () => [...postKeys.all, "detail"] as const,
detail: (id: number) => [...postKeys.details(), id] as const,
};QueryClient in Server Component - Creating QueryClient outside a component or in a Server Component causes shared state between requests. Fix: Create QueryClient inside useState in the provider, or create a new instance per request in server prefetch functions.
staleTime vs gcTime confusion - staleTime controls refetch behavior; gcTime controls cache eviction. Setting staleTime: Infinity prevents refetches but the cache can still be garbage collected. Fix: Understand both: staleTime = "how long is data considered fresh?" gcTime = "how long to keep inactive cache entries?"
Query key must be serializable - Functions, class instances, or circular references in query keys cause issues. Fix: Use only strings, numbers, objects, and arrays in query keys.
Mutations don't auto-invalidate - Unlike some frameworks, mutations don't automatically refetch related queries. Fix: Use onSettled or onSuccess callback with queryClient.invalidateQueries().
Double fetching in Strict Mode - React Strict Mode in development mounts components twice, causing double fetches. Fix: This is expected behavior in dev mode only. In production, queries fetch once. TanStack Query deduplicates concurrent identical requests.
Optimistic update type mismatch - The optimistic data shape must match the query data exactly. Fix: Use queryClient.getQueryData<Post[]>() with the correct generic type, and ensure optimistic items have all required fields.
| Library | Best For | Trade-off |
|---|---|---|
| TanStack Query | Complex client-side data fetching | Additional dependency, learning curve |
| SWR | Simple data fetching with caching | Fewer features (no mutations, no devtools) |
| Next.js Server Components | Server-side data fetching | No client-side cache, no background refetch |
| RTK Query | Redux-based apps | Tied to Redux, heavier setup |
| Apollo Client | GraphQL APIs | Overkill for REST, larger bundle |
queryKey is an array that uniquely identifies a query's cached data["posts", { status: "draft" }] worksstaleTime = how long data is considered fresh (no refetch during this window)gcTime (formerly cacheTime) = how long inactive cache entries are kept before garbage collectionstaleTime: Infinity prevents all automatic refetchesgcTime is 5 minutes; after that, unmounted query data is removedconst queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createPost,
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["posts"] });
},
});Use invalidateQueries in onSettled or onSuccess to refetch stale data.
onMutate: cancel in-flight queries, snapshot current data, update cache optimisticallyonError: rollback to the snapshot if the mutation failsonSettled: invalidate queries to refetch from the server regardless of success/failureQueryClient is shared between all server requestsQueryClient inside useState in the provider componentconst { data: user } = useQuery({
queryKey: ["user"],
queryFn: fetchUser,
});
const { data: posts } = useQuery({
queryKey: ["posts", user?.id],
queryFn: () => fetchUserPosts(user!.id),
enabled: !!user?.id,
});The enabled option prevents the query from running until the dependency is available.
QueryClientdehydrate(queryClient) to serialize the cache<HydrationBoundary state={dehydratedState}>useQuery with the same key get instant dataconst { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["posts", "infinite"],
queryFn: ({ pageParam }) =>
fetch(`/api/posts?cursor=${pageParam}`).then(r => r.json()),
initialPageParam: "",
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const allPosts = data?.pages.flatMap(p => p.posts) ?? [];// useQuery infers data type from queryFn return type
const { data } = useQuery({
queryKey: ["posts"],
queryFn: async (): Promise<Post[]> => {
const res = await fetch("/api/posts");
return res.json();
},
});
// data is Post[] | undefined
// useMutation generics: <TData, TError, TVariables, TContext>
const mutation = useMutation<Post, Error, { title: string }>({
mutationFn: (vars) => createPost(vars),
});const postKeys = {
all: ["posts"] as const,
lists: () => [...postKeys.all, "list"] as const,
list: (filters: Filters) => [...postKeys.lists(), filters] as const,
detail: (id: number) => [...postKeys.all, "detail", id] as const,
};invalidateQueries({ queryKey: postKeys.all }) clears all post queriesas const provides strict tuple types for better TypeScript inference<li
onMouseEnter={() => {
queryClient.prefetchQuery({
queryKey: ["post", post.id],
queryFn: () => fetchPost(post.id),
});
}}
>
<Link href={`/posts/${post.id}`}>{post.title}</Link>
</li>The data is cached before the user clicks, making the next page load instant.
Reviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥