import ComparisonTable from ’../../components/ComparisonTable.astro’;
Both TanStack Query and SWR solve the same core problem: managing server state in React without manual loading/error/cache boilerplate. They share a similar mental model but differ significantly in power and complexity.
Quick Verdict
Choose TanStack Query if: You need mutations with optimistic updates, complex cache invalidation, offline support, or rich DevTools.
Choose SWR if: You’re building a simple read-heavy app, want minimal bundle size, or value an extremely simple API.
Feature Comparison
<ComparisonTable headers={[“Feature”, “TanStack Query”, “SWR”]} rows={[ [“Bundle size”, “~13KB gzipped”, “~4KB gzipped”], [“Mutations”, “Full (useMutation)”, “Manual (fetch + mutate)”], [“Optimistic updates”, “Built-in”, “Manual”], [“DevTools”, “Dedicated DevTools UI”, “No official DevTools”], [“Offline support”, “Built-in”, “Limited”], [“Infinite queries”, “useInfiniteQuery”, “useSWRInfinite”], [“Suspense”, “Supported”, “Supported”], [“TypeScript”, “Excellent”, “Good”], [“Cache invalidation”, “Fine-grained control”, “Key-based revalidation”], [“Prefetching”, “queryClient.prefetchQuery”, “useSWR preload”], [“Stale time”, “Configurable per query”, “Configurable per hook”], [“Background refetch”, “Yes”, “Yes (focus + interval)”], ]} />
Basic Data Fetching
Both libraries follow the same pattern — key + fetcher:
TanStack Query:
import { useQuery, useQueryClient } from '@tanstack/react-query';
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user';
}
// Basic query
function UserProfile({ userId }: { userId: number }) {
const { data, isPending, isError, error } = useQuery({
queryKey: ['users', userId],
queryFn: async () => {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error('Failed to fetch user');
return res.json() as Promise<User>;
},
staleTime: 5 * 60 * 1000, // 5 minutes
});
if (isPending) return <UserSkeleton />;
if (isError) return <ErrorMessage error={error} />;
return <UserCard user={data} />;
}
SWR:
import useSWR from 'swr';
const fetcher = async (url: string) => {
const res = await fetch(url);
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
};
function UserProfile({ userId }: { userId: number }) {
const { data, error, isLoading } = useSWR<User>(
`/api/users/${userId}`,
fetcher,
{ dedupingInterval: 5 * 60 * 1000 }
);
if (isLoading) return <UserSkeleton />;
if (error) return <ErrorMessage error={error} />;
return <UserCard user={data!} />;
}
Both look similar for basic fetching. The differences emerge with mutations and cache management.
Mutations
This is where TanStack Query significantly outpaces SWR:
TanStack Query — useMutation with optimistic updates:
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface UpdateUserInput {
name: string;
email: string;
}
function EditUserForm({ userId }: { userId: number }) {
const queryClient = useQueryClient();
const updateUser = useMutation({
mutationFn: async (input: UpdateUserInput) => {
const res = await fetch(`/api/users/${userId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
return res.json() as Promise<User>;
},
// Optimistic update
onMutate: async (newData) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['users', userId] });
// Snapshot current value
const previousUser = queryClient.getQueryData<User>(['users', userId]);
// Optimistically update cache
queryClient.setQueryData<User>(['users', userId], (old) => ({
...old!,
...newData,
}));
return { previousUser };
},
// Rollback on error
onError: (error, variables, context) => {
queryClient.setQueryData(['users', userId], context?.previousUser);
},
// Always refetch after
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['users', userId] });
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
updateUser.mutate({
name: formData.get('name') as string,
email: formData.get('email') as string,
});
};
return (
<form onSubmit={handleSubmit}>
<input name="name" />
<input name="email" />
<button disabled={updateUser.isPending}>
{updateUser.isPending ? 'Saving...' : 'Save'}
</button>
{updateUser.isError && <p>Error: {updateUser.error.message}</p>}
</form>
);
}
SWR — mutations require manual implementation:
import useSWR, { useSWRConfig } from 'swr';
function EditUserForm({ userId }: { userId: number }) {
const { mutate } = useSWRConfig();
const { data: user } = useSWR<User>(`/api/users/${userId}`, fetcher);
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<Error | null>(null);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsPending(true);
setError(null);
const formData = new FormData(e.currentTarget);
const newData = {
name: formData.get('name') as string,
email: formData.get('email') as string,
};
try {
// Optimistic update (manual)
mutate(
`/api/users/${userId}`,
async () => {
const res = await fetch(`/api/users/${userId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newData),
});
return res.json();
},
{
optimisticData: { ...user, ...newData },
rollbackOnError: true,
}
);
} catch (err) {
setError(err as Error);
} finally {
setIsPending(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input name="name" defaultValue={user?.name} />
<input name="email" defaultValue={user?.email} />
<button disabled={isPending}>
{isPending ? 'Saving...' : 'Save'}
</button>
{error && <p>Error: {error.message}</p>}
</form>
);
}
SWR requires manual state management for loading and errors — TanStack Query’s useMutation handles these automatically.
Infinite Queries
TanStack Query — useInfiniteQuery:
import { useInfiniteQuery } from '@tanstack/react-query';
interface PostsPage {
posts: Post[];
nextCursor: string | null;
}
function InfinitePostList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: async ({ pageParam }) => {
const url = pageParam
? `/api/posts?cursor=${pageParam}`
: '/api/posts';
const res = await fetch(url);
return res.json() as Promise<PostsPage>;
},
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const posts = data?.pages.flatMap((page) => page.posts) ?? [];
return (
<div>
{posts.map((post) => <PostCard key={post.id} post={post} />)}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : 'Load more'}
</button>
</div>
);
}
SWR — useSWRInfinite:
import useSWRInfinite from 'swr/infinite';
const getKey = (pageIndex: number, previousPageData: PostsPage | null) => {
if (previousPageData && !previousPageData.nextCursor) return null;
if (pageIndex === 0) return '/api/posts';
return `/api/posts?cursor=${previousPageData!.nextCursor}`;
};
function InfinitePostList() {
const { data, size, setSize, isLoading } = useSWRInfinite<PostsPage>(
getKey,
fetcher
);
const posts = data?.flatMap((page) => page.posts) ?? [];
const hasNextPage = data?.[data.length - 1]?.nextCursor != null;
return (
<div>
{posts.map((post) => <PostCard key={post.id} post={post} />)}
<button
onClick={() => setSize(size + 1)}
disabled={!hasNextPage || isLoading}
>
{isLoading ? 'Loading...' : 'Load more'}
</button>
</div>
);
}
Cache Invalidation
TanStack Query — granular cache control:
const queryClient = useQueryClient();
// Invalidate specific query
queryClient.invalidateQueries({ queryKey: ['users', userId] });
// Invalidate all user queries
queryClient.invalidateQueries({ queryKey: ['users'] });
// Update cache directly (no refetch)
queryClient.setQueryData<User[]>(['users'], (old) =>
old?.map(user => user.id === userId ? { ...user, name: 'New Name' } : user)
);
// Prefetch before navigation
await queryClient.prefetchQuery({
queryKey: ['users', userId],
queryFn: () => fetchUser(userId),
});
// Remove from cache
queryClient.removeQueries({ queryKey: ['users', userId] });
SWR — key-based revalidation:
import { mutate } from 'swr';
// Revalidate specific key
mutate('/api/users/123');
// Revalidate all matching keys (regex)
mutate((key) => typeof key === 'string' && key.startsWith('/api/users'), undefined, { revalidate: true });
// Update cache directly
mutate('/api/users/123', updatedUser, { revalidate: false });
// Global mutate
const { mutate: globalMutate } = useSWRConfig();
globalMutate('/api/users');
SWR’s key-based approach is simpler but less expressive. TanStack Query’s hierarchical invalidation (['users'] invalidates all user queries) is more powerful.
Provider Setup
TanStack Query:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
retry: 2,
refetchOnWindowFocus: true,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<Router />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
SWR:
import { SWRConfig } from 'swr';
// Provider optional — can configure globally
function App() {
return (
<SWRConfig
value={{
fetcher: (url: string) => fetch(url).then(r => r.json()),
revalidateOnFocus: true,
shouldRetryOnError: true,
errorRetryCount: 2,
dedupingInterval: 60 * 1000,
}}
>
<Router />
</SWRConfig>
);
}
SWR’s provider is optional — a global fetcher can be configured without wrapping the app.
When to Choose Each
Choose TanStack Query:
- Apps with significant mutation requirements (forms, CRUD)
- Need optimistic updates with automatic rollback
- Want visual DevTools to debug cache state
- Complex cache invalidation patterns (invalidating related queries after mutation)
- Offline-capable applications
- Large teams where consistent patterns matter
Choose SWR:
- Read-heavy dashboards with minimal writes
- Bundle size is a constraint (4KB vs 13KB)
- Simple API — one hook, one data source
- Next.js apps (SWR is built by Vercel, integrates naturally)
- Prototype or small-to-medium app
Bottom Line
TanStack Query is the more capable library and the right choice for most production applications — the mutation handling, DevTools, and cache management alone justify the slightly larger bundle size. SWR excels in simplicity for read-heavy apps where bundle size matters and mutations are minimal. If you’re unsure, TanStack Query’s richer API means you won’t hit its limits as your app grows.