Every React application that communicates with an API eventually runs into the same problems: race conditions when a user navigates quickly, duplicate network requests from multiple components mounting simultaneously, stale data sitting in the UI while the server has fresher values, and the boilerplate burden of managing loading and error state manually across dozens of hooks.
The naive useEffect + useState fetch pattern forces you to solve all of these problems yourself, repeatedly, for every endpoint your application touches. SWR and TanStack Query exist to solve them once at the library level. They share the same broad goal — managing server state in React — but make meaningfully different architectural choices that make one clearly better than the other depending on your application's needs.
This guide examines why the basic fetch hook fails at scale, how each library addresses those failures, and provides code examples for the same use cases in both so you can make an informed choice rather than defaulting to whichever one you encountered first.
Why useEffect-Based Fetch Hooks Break at Scale
Building a data fetching hook from scratch with useEffect looks straightforward initially:
// The naive pattern every React developer writes first
function useUserData(userId: string) {
const [data, setData] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, [userId]);
return { data, loading, error };
}
This hook has at least four production-grade problems that are not immediately obvious.
Race condition: If userId changes quickly (user navigates between profiles), two requests fire simultaneously. Whichever resolves last wins — but that is not guaranteed to be the most recent request. The user sees profile A's data loaded, then overwritten by profile B's slower response, then overwritten again by profile A's response arriving late.
No deduplication: If three components on the same page all call useUserData("123"), three separate requests fire for the same data. The library-level solutions deduplicate these automatically — a single request fires and all three components receive the same cached response.
No cache: Navigate away and back, and the hook re-fetches from scratch, showing a loading spinner for data the user just saw. Libraries keep a cache keyed by query key, serve stale data immediately while revalidating in the background, and avoid the jarring spinner-on-every-navigation pattern.
No background synchronisation: The naive hook fetches once on mount. If the server data changes while the user is on the page, the UI stays stale until they refresh. SWR and TanStack Query both support background polling and revalidation on window focus.
SWR: Cache-First, Read-Optimised, Minimal API
SWR (stale-while-revalidate) is Vercel's data fetching library, named after the HTTP cache-control directive that serves stale content while fetching fresh content in the background. It prioritises simplicity and bundle size — at roughly 4 KB gzipped, it is the smallest fully-featured client-side data fetching library available.
The core API takes a key (usually the request URL) and a fetcher function:
import useSWR from "swr";
const fetcher = (url: string) => fetch(url).then((res) => res.json());
function UserProfile({ userId }: { userId: string }) {
const { data, error, isLoading } = useSWR(
`/api/users/${userId}`,
fetcher
);
if (isLoading) return <Skeleton />;
if (error) return <ErrorMessage error={error} />;
return <Profile user={data} />;
}
Configure a global fetcher and defaults once via SWRConfig at your app root so individual hooks do not need to pass a fetcher every time:
// app.tsx or _app.tsx
import { SWRConfig } from "swr";
const globalFetcher = (url: string) =>
fetch(url).then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
});
export default function App({ children }: { children: React.ReactNode }) {
return (
<SWRConfig
value={{
fetcher: globalFetcher,
revalidateOnFocus: true,
revalidateOnReconnect: true,
dedupingInterval: 2000,
errorRetryCount: 3,
}}
>
{children}
</SWRConfig>
);
}
SWR handles mutations via the mutate function. The API is straightforward for optimistic updates — update the local cache immediately and revert on error:
import useSWR, { useSWRConfig } from "swr";
function LikeButton({ postId }: { postId: string }) {
const { data: post, mutate } = useSWR(`/api/posts/${postId}`);
const { mutate: globalMutate } = useSWRConfig();
const handleLike = async () => {
// Optimistic update — update local cache immediately
await mutate(
async () => {
await fetch(`/api/posts/${postId}/like`, { method: "POST" });
return { ...post, likes: post.likes + 1 };
},
{
optimisticData: { ...post, likes: post.likes + 1 },
rollbackOnError: true,
revalidate: false,
}
);
};
return <button onClick={handleLike}>{post?.likes} Likes</button>;
}
SWR also supports middleware — functions that wrap the useSWR hook to add cross-cutting behaviour like request logging, error monitoring, or caching adapters. This pattern is underused but powerful for teams that want consistent behaviour across all data fetching without modifying individual hooks.
TanStack Query: Richer Mutations, Smarter Cache Invalidation
TanStack Query (the library formerly known as React Query, now framework-agnostic) is more opinionated than SWR and significantly more powerful for applications where mutations drive most of the complexity. At around 13 KB gzipped, it costs roughly 3x the bundle size of SWR — a meaningful difference for performance-sensitive applications.
The setup requires a QueryClient and a QueryClientProvider:
// main.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // Data is fresh for 1 minute
gcTime: 5 * 60 * 1000, // Cache kept for 5 minutes after last use
retry: 3,
refetchOnWindowFocus: true,
},
},
});
ReactDOM.createRoot(document.getElementById("root")!).render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
The useQuery hook for reads is functionally similar to SWR:
import { useQuery } from "@tanstack/react-query";
function UserProfile({ userId }: { userId: string }) {
const { data, isPending, isError, error } = useQuery({
queryKey: ["user", userId],
queryFn: () => fetch(`/api/users/${userId}`).then((r) => r.json()),
enabled: !!userId, // Don't fetch if userId is empty
});
if (isPending) return <Skeleton />;
if (isError) return <ErrorMessage error={error} />;
return <Profile user={data} />;
}
Note isPending (v5 terminology) rather than isLoading. TanStack Query v5 splits the loading state: isPending means no cached data exists and the query is fetching, while isFetching means a fetch is in progress but stale cached data is available. This distinction eliminates an entire class of "spinner flashes on navigation" bugs.
Where TanStack Query Pulls Ahead: useMutation
The useMutation hook is where TanStack Query significantly outpaces SWR for applications with complex write operations. It provides a structured API for the full mutation lifecycle — loading, success, error, retry — plus first-class cache invalidation:
import { useMutation, useQueryClient } from "@tanstack/react-query";
function CreatePostForm() {
const queryClient = useQueryClient();
const createPost = useMutation({
mutationFn: (newPost: { title: string; body: string }) =>
fetch("/api/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newPost),
}).then((r) => r.json()),
onMutate: async (newPost) => {
// Cancel outgoing refetches that would overwrite our optimistic update
await queryClient.cancelQueries({ queryKey: ["posts"] });
// Snapshot current cache
const previousPosts = queryClient.getQueryData(["posts"]);
// Optimistically update cache
queryClient.setQueryData(["posts"], (old: Post[]) => [
{ id: "temp-id", ...newPost },
...old,
]);
return { previousPosts };
},
onError: (err, newPost, context) => {
// Roll back on error
queryClient.setQueryData(["posts"], context?.previousPosts);
},
onSuccess: () => {
// Invalidate posts list to refetch from server
queryClient.invalidateQueries({ queryKey: ["posts"] });
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
createPost.mutate({
title: form.title.value,
body: form.body.value,
});
};
return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="Title" />
<textarea name="body" placeholder="Content" />
<button disabled={createPost.isPending}>
{createPost.isPending ? "Posting..." : "Publish"}
</button>
{createPost.isError && <p>Error: {createPost.error.message}</p>}
</form>
);
}
The invalidateQueries call in onSuccess is what makes TanStack Query's cache model compelling. After a successful create, all queries with the ["posts"] key prefix are marked stale and will refetch the next time they are observed. This means a paginated posts list, a "recent posts" sidebar widget, and a post count badge — all using different query keys that include "posts" — all update automatically without any manual state threading.
Building Custom Hooks on Top of Either Library
Whether you choose SWR or TanStack Query, wrapping library calls in custom hooks is the right abstraction layer. This keeps API logic colocated with the data shape it returns and makes it easy to change the fetching strategy in one place.
// hooks/use-products.ts — TanStack Query version
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { Product } from "@/types";
const productKeys = {
all: ["products"] as const,
list: (filters: Record<string, string>) => ["products", "list", filters] as const,
detail: (id: string) => ["products", "detail", id] as const,
};
export function useProducts(filters: Record<string, string> = {}) {
return useQuery({
queryKey: productKeys.list(filters),
queryFn: () =>
fetch(`/api/products?${new URLSearchParams(filters)}`).then((r) => r.json()),
});
}
export function useProduct(id: string) {
return useQuery({
queryKey: productKeys.detail(id),
queryFn: () => fetch(`/api/products/${id}`).then((r) => r.json()),
enabled: !!id,
});
}
export function useUpdateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Product> }) =>
fetch(`/api/products/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}).then((r) => r.json()),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: productKeys.all });
queryClient.invalidateQueries({ queryKey: productKeys.detail(id) });
},
});
}
The query key factory pattern (productKeys) is a TanStack Query convention that keeps query keys consistent and makes invalidation precise — you can invalidate all product queries, just the list queries with specific filters, or a specific product detail without affecting others.
The RSC Alternative: When You Don't Need Either
In Next.js App Router, React Server Components change the calculus for read operations. An async Server Component can fetch data directly without any client-side library:
// app/products/page.tsx — Server Component
async function ProductsPage() {
// This runs on the server — no useQuery needed
const products = await fetch("https://api.example.com/products", {
next: { revalidate: 60 }, // ISR: revalidate every 60 seconds
}).then((r) => r.json());
return (
<ul>
{products.map((p: Product) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
Next.js automatically deduplicates identical fetch calls within the same server render cycle, so multiple Server Components fetching the same URL get a single request. The cache behaviour is controlled via the next option on fetch rather than a client-side query client.
The rule of thumb for App Router projects: use Server Components with direct fetch for data that is available at render time and does not need real-time updates. Add TanStack Query or SWR only in Client Components that need mutations, polling, optimistic updates, or user-triggered refetches. Many dashboards end up with this split architecture — server-fetched initial data with client-side TanStack Query for the interactive layer.
Frequently Asked Questions
Should I migrate from React Query v3 to TanStack Query v5?
Yes, for any active project the migration is worth doing. TanStack Query v5 removed the onSuccess/onError/onSettled callbacks from useQuery — pushing side effects into useEffect where they belong. The loading state is now split into isPending (no cache, fetching) and isFetching (background revalidation), eliminating a category of stale-data display bugs. Query cancellation via AbortController is now first-class. The TanStack migration guide covers the mechanical changes thoroughly — for a medium-sized app, budget 2–4 hours for the switch.
Does TanStack Query work with React Server Components?
TanStack Query is client-side only and cannot run inside React Server Components, which have no access to browser APIs or React context. In Next.js App Router, the recommended split is: fetch read data directly inside async Server Components using native fetch (Next.js deduplicates and caches automatically), and use TanStack Query in Client Components for mutations, polling, and real-time synchronisation. Many App Router applications end up using both patterns in the same codebase — server-side initial fetches with client-side TanStack Query for interactive updates.
Can I use both SWR and TanStack Query in the same project?
You can, but there is rarely a good reason to. Mixing them creates two separate caches that do not share data, two separate loading/error patterns for developers to navigate, and roughly 17 KB of combined bundle weight (SWR at ~4 KB plus TanStack Query at ~13 KB). If you need SWR's simplicity for reads but TanStack Query's mutation API, TanStack Query v5 has refined its read ergonomics to the point where it covers SWR's use cases without a second dependency. Pick one and apply it consistently — the codebase coherence is worth more than any marginal feature difference between the two.