The typical React data-fetching pattern looks something like this:
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(data => { setUser(data); setLoading(false); })
.catch(err => { setError(err); setLoading(false); });
}, [userId]);
if (loading) return <Spinner />;
if (error) return <Error message={error.message} />;
return <Profile user={user!} />;
}
This has about 6 silent bugs. Let's talk about them.
What's Wrong With The Pattern Above
- No deduplication — two components mounting simultaneously fire two identical requests
- No caching — navigating away and back refetches every time
- No background refresh — data goes stale silently
- Race conditions — if
userIdchanges quickly, responses can resolve out of order - No retry — a single network blip shows an error forever
- No loading state persistence — going back to a list shows a spinner even when data is fresh
TanStack Query eliminates all of these. Here's the same component:
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
});
if (isLoading) return <Spinner />;
if (error) return <Error message={error.message} />;
return <Profile user={user} />;
}
12 lines → 7 lines, and now it has: deduplication, caching, background refetching, race condition safety, automatic retries, and stale-while-revalidate.
Setup
npm install @tanstack/react-query
Wrap your app:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
retry: 2,
},
},
});
export function App() {
return (
<QueryClientProvider client={queryClient}>
<Router />
</QueryClientProvider>
);
}
Mutations
For writes, use useMutation:
function CreatePostForm() {
const queryClient = useQueryClient();
const { mutate, isPending } = useMutation({
mutationFn: (newPost: NewPost) =>
fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(newPost),
}).then(r => r.json()),
onSuccess: () => {
// Invalidate the posts list so it refetches
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
return (
<form onSubmit={e => {
e.preventDefault();
mutate({ title: 'New Post', body: '...' });
}}>
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Post'}
</button>
</form>
);
}
Optimistic Updates
This is where TanStack Query really shines:
const { mutate } = useMutation({
mutationFn: toggleLike,
onMutate: async ({ postId }) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['post', postId] });
// Snapshot current value
const previous = queryClient.getQueryData(['post', postId]);
// Optimistically update
queryClient.setQueryData(['post', postId], (old: Post) => ({
...old,
liked: !old.liked,
likeCount: old.liked ? old.likeCount - 1 : old.likeCount + 1,
}));
return { previous };
},
onError: (err, { postId }, context) => {
// Roll back on error
queryClient.setQueryData(['post', postId], context?.previous);
},
});
The UI updates instantly on click. If the server call fails, it rolls back automatically.
Structuring Your Query Keys
Use a factory pattern to keep keys consistent:
const userQueries = {
all: () => ['users'] as const,
lists: () => [...userQueries.all(), 'list'] as const,
detail: (id: string) => [...userQueries.all(), 'detail', id] as const,
posts: (userId: string) => [...userQueries.detail(userId), 'posts'] as const,
};
// Usage
useQuery({ queryKey: userQueries.detail(userId), queryFn: fetchUser });
useQuery({ queryKey: userQueries.posts(userId), queryFn: fetchUserPosts });
// Invalidate all user queries
queryClient.invalidateQueries({ queryKey: userQueries.all() });
// Invalidate just one user's data
queryClient.invalidateQueries({ queryKey: userQueries.detail(userId) });
Prefetching for Perceived Performance
function UserListItem({ user }: { user: User }) {
const queryClient = useQueryClient();
return (
<Link
href={`/users/${user.id}`}
onMouseEnter={() => {
// Prefetch on hover — data is ready before they click
queryClient.prefetchQuery({
queryKey: userQueries.detail(user.id),
queryFn: () => fetchUser(user.id),
});
}}
>
{user.name}
</Link>
);
}
When TanStack Query Is Overkill
For truly simple, one-off fetches that run once and never change, a plain fetch in a Server Component is simpler. TanStack Query's value compounds in client-heavy apps with: shared data between many components, frequent mutations, real-time feel requirements, or complex cache invalidation needs.
If your app is primarily server-rendered Next.js pages with occasional interactivity, lean on Server Components + fetch with revalidate, and reach for TanStack Query only for the interactive client-side pieces.
Tools in this post
Related Tool
JSON Parser & Formatter
Validate, format, and minify JSON data with error highlighting.
Try it freeRelated Tool
XML Formatter & Beautifier
Beautify and minify XML code with syntax highlighting.
Try it freeWritten by
DebuggerMe TeamThe DebuggerMe team builds developer tools, writes technical content, and helps teams ship better software.
Related Articles
All articles →Getting Started with Next.js 16 — A Complete Guide
Everything you need to know to build fast, modern web applications with Next.js 16 App Router, Server Components, and TypeScript. From project setup to production deployment.
React Server Components in Depth — What They Are and When to Use Them
React Server Components fundamentally change how we think about rendering. This guide breaks down how they work, how they differ from Client Components, and the patterns that will make your Next.js apps faster.
CSS Grid vs Flexbox — When to Use Each (With Real Examples)
The grid vs flexbox debate persists because developers treat them as alternatives. They're not — they solve different problems. This guide shows you exactly when to reach for each one.