TanStack Query - Server state management with caching, background refetching, and optimistic updates
Recipe
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.
Working Example
// 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:
useQueryfor data fetching with loading and error statesuseMutationwith optimistic updates and rollback on error- Cache invalidation with
invalidateQueriesafter mutations useQueryClientto access the cache directly- Custom hooks wrapping queries for reuse
Deep Dive
How It Works
- TanStack Query manages a client-side cache keyed by
queryKeyarrays; identical keys share cached data staleTimecontrols how long data is considered fresh; within this window, no refetch occursgcTime(formerlycacheTime) controls how long inactive cache entries are kept (default 5 minutes)- Background refetching happens automatically on window focus, network reconnect, and interval (if configured)
queryKeysupports nested arrays and objects; keys are serialized and compared deeply- Mutations do not use the cache; they fire and forget, but can trigger cache updates via callbacks
Variations
Dependent 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>
);
}TypeScript Notes
useQueryinfers the data type fromqueryFnreturn type; annotate the function for explicit typingqueryKeyacceptsreadonly unknown[]; useas constfor strict tuple typesuseMutationgenerics:useMutation<TData, TError, TVariables, TContext>- The
selectoption 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,
};Gotchas
-
QueryClient in Server Component — Creating
QueryClientoutside a component or in a Server Component causes shared state between requests. Fix: CreateQueryClientinsideuseStatein the provider, or create a new instance per request in server prefetch functions. -
staleTime vs gcTime confusion —
staleTimecontrols refetch behavior;gcTimecontrols cache eviction. SettingstaleTime: Infinityprevents 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
onSettledoronSuccesscallback withqueryClient.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.
Alternatives
| 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 |
FAQs
What is queryKey and why does it matter?
queryKeyis an array that uniquely identifies a query's cached data- Identical keys share the same cache entry and deduplicate concurrent requests
- Keys are serialized and compared deeply, so
["posts", { status: "draft" }]works - Changing the key triggers a new fetch
What is the difference between staleTime and gcTime?
staleTime= how long data is considered fresh (no refetch during this window)gcTime(formerlycacheTime) = how long inactive cache entries are kept before garbage collection- Setting
staleTime: Infinityprevents all automatic refetches - Default
gcTimeis 5 minutes; after that, unmounted query data is removed
How do I invalidate the cache after a mutation?
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createPost,
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["posts"] });
},
});Use invalidateQueries in onSettled or onSuccess to refetch stale data.
How do optimistic updates work with useMutation?
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/failure- The optimistic data shape must match the query data exactly
Gotcha: Why does creating QueryClient outside a component cause shared state across requests?
- A module-level
QueryClientis shared between all server requests - One user's data could leak into another user's response
- Fix: create
QueryClientinsideuseStatein the provider component - For server prefetching, create a new instance per request
How do I implement dependent queries (fetch B only after A)?
const { 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.
How do I set up SSR hydration with Next.js App Router?
- Prefetch queries in a Server Component using a new
QueryClient - Call
dehydrate(queryClient)to serialize the cache - Wrap the client component with
<HydrationBoundary state={dehydratedState}> - Client components using
useQuerywith the same key get instant data
How do I implement infinite scroll with useInfiniteQuery?
const { 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) ?? [];Gotcha: Why do I see double fetches in development mode?
- React Strict Mode mounts components twice in development, triggering two fetches
- This only happens in dev mode, not in production
- TanStack Query deduplicates concurrent identical requests automatically
- Do not disable Strict Mode to work around this
How do I type useQuery and useMutation properly in TypeScript?
// 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),
});What is the query key factory pattern and why use it?
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,
};- Centralizes key definitions to prevent typos and inconsistencies
- Makes invalidation easier:
invalidateQueries({ queryKey: postKeys.all })clears all post queries as constprovides strict tuple types for better TypeScript inference
How do I prefetch data on hover for instant navigation?
<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.
Related
- SWR — Lighter alternative for simpler use cases
- Next.js Server Components — Server-side fetching before hydration
- Zustand — Client state management (separate from server state)
- Prisma — Database access for the API endpoints that TanStack Query calls