useEffect plus useState is not a data-fetching solution, it is the raw materials for one. The version everybody writes has no deduplication, no cache, and a race condition that shows up the moment a prop changes twice in quick succession.
TanStack Query is a cache with a React binding attached, and that framing explains most of its API. Here is the pattern it replaces:
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
Number four is the one worth dwelling on, because it is the hardest to reproduce and the easiest to ship. If userId changes from 1 to 2, two requests are in flight. Nothing guarantees they resolve in order. If the response for 1 arrives second, it calls setUser last and the profile for user 1 renders under a page that says user 2.
The fix in raw useEffect is an ignore flag in the cleanup function:
useEffect(() => {
let ignore = false;
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(data => { if (!ignore) setUser(data); });
return () => { ignore = true; };
}, [userId]);
Every hand-rolled fetch in your codebase needs that, and most do not have it.
TanStack Query eliminates all six. 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>
);
}
staleTime and gcTime are the two settings that matter
Almost every complaint about TanStack Query refetching too much or too little comes down to these two, and they are routinely confused because both are durations measured from the same moment.
staleTimeis how long data is considered fresh. While fresh, mounting another component with the same key serves the cache and fires no request. Default:0, meaning data is stale immediately.gcTimeis how long an unused query stays in memory after its last observer unmounts. Default: five minutes.
The default staleTime: 0 is what surprises people. It does not mean "refetch constantly", it means "refetch on the next trigger", and the triggers are component mount, window refocus, and network reconnect. Navigating back to a list therefore refetches, which reads as excessive on a dashboard and is exactly right on a trading screen.
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000, // fresh for five minutes, no refetch on mount
gcTime: 30 * 60 * 1000, // keep it cached half an hour after unmount
});
A rule that holds up: set staleTime to how long you are willing to show a stale value, and leave gcTime alone unless memory is a problem. Reference data such as a country list can sit at Infinity.
[!NOTE]
gcTimewas calledcacheTimebefore v5. Older answers on Stack Overflow use the old name, which is a common source of confusion when the option appears to do nothing.
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>
);
}
Errors, retries, and the thing fetch gets wrong
One detail catches everyone migrating from raw fetch: fetch does not reject on HTTP error statuses. A 404 or a 500 resolves normally with ok: false, so a queryFn written as fetch(url).then(r => r.json()) reports success and hands your component a parsed error body.
The queryFn has to throw for the query to enter its error state:
const queryFn = async () => {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return res.json();
};
Retries then behave sensibly. TanStack Query retries three times by default with exponential backoff, which is right for a network blip and wrong for a 404, since no number of retries will conjure the record. Make it conditional:
useQuery({
queryKey: ['user', userId],
queryFn,
retry: (failureCount, error) => {
if (error instanceof HttpError && error.status >= 400 && error.status < 500) {
return false; // client errors are not transient
}
return failureCount < 3;
},
});
Throwing a typed error rather than a bare Error is what makes this readable, and it is worth the extra class.
isLoading versus isFetching versus isPending
Three flags, and picking the wrong one produces a spinner that flashes on every background refetch.
| Flag | True when |
|---|---|
isPending | there is no data yet, the genuine first load |
isFetching | a request is in flight, including background refetches |
isLoading | isPending && isFetching, so a first load actively fetching |
Use isPending for the full-page skeleton. Use isFetching for a subtle indicator such as a spinner in the corner, so that a stale-while-revalidate refresh does not blank out content the user is reading. Rendering a skeleton on isFetching throws away the main benefit of having a cache.
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.
The two are not mutually exclusive, and the combination is often the right answer: fetch on the server, hand the result to the client as initial data, and let TanStack Query own it from there. That way the first paint has no spinner and subsequent interactions get caching and background refresh.
// Server Component
const user = await fetchUser(userId);
return <UserProfile userId={userId} initialUser={user} />;
// Client Component
useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
initialData: initialUser,
});
The distinction worth keeping straight: initialData is written into the cache and treated as real, so staleTime applies to it. placeholderData is displayed but never cached, so a fetch fires immediately. Use initialData for server-rendered content you trust, and placeholderData for a skeleton value you do not.
One decision rule for the whole article: if the data is read once per page load and never mutated, a Server Component and fetch is less machinery and you should use it. The moment two components need the same data, or a mutation has to invalidate it, a cache earns its place, and writing that cache yourself is how the six bugs at the top of this post get into a codebase.
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
Jamith NimanthaSoftware developer. Builds the DebuggerMe tools and writes about the things he runs into shipping them.
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.