TanStack Query vs SWR vs Apollo Client 2026
TL;DR
TanStack Query v5 is the best general-purpose data fetching library for React in 2026 — 12M+ weekly downloads, rich mutation handling, and excellent devtools. SWR wins on bundle size (4 KB vs 13 KB) and simplicity for Vercel/Next.js apps with straightforward data needs. Apollo Client remains the gold standard for GraphQL-heavy applications with complex entity relationships — nothing else comes close for normalized graph caching.
Key Takeaways
- TanStack Query: 12.3M weekly downloads, 48K GitHub stars — overtook SWR in 2024 and widened the gap
- SWR: 4.9M weekly downloads, 32K GitHub stars — Vercel-backed, 4 KB gzipped, Next.js native
- Apollo Client: 1.4M weekly downloads, 47.4K GitHub stars — GraphQL specialist, highest complexity
- TanStack Query is 13.4 KB gzipped vs SWR at 4.2 KB — 3x size for 3x feature depth
- Apollo's normalized cache (InMemoryCache) is the best in class for complex GraphQL entity graphs
- SWR added useSWRMutation in v2 but manual optimistic update handling is still more complex than TanStack
-
TanStack Query v5 removed the
status === "loading"state — now usesisPendingconsistently
Why Data Fetching Libraries Matter
useEffect + fetch leaves you reinventing: loading states, error boundaries, background refetching, cache invalidation, pagination, optimistic updates, and request deduplication. Each of these libraries solves the same core problem with different priorities.
The API type your backend exposes is the most important selection criterion. REST and tRPC apps thrive with TanStack Query or SWR. GraphQL apps are served best by Apollo Client (or urql — see Apollo Client vs urql 2026). The choice gets more nuanced when you have complex mutation patterns, real-time requirements, or bundle size constraints.
One of the most common mistakes teams make is choosing a data fetching library based on its simplest use case. SWR looks appealingly minimal in a tutorial that fetches user data. But tutorials don't show you what happens when you need to optimistically update a list after adding an item, or invalidate a cached query when a related mutation succeeds, or implement infinite scroll with cursor-based pagination. The libraries diverge significantly in these scenarios, and switching later is painful.
Server components in Next.js App Router have changed the calculus somewhat. For data that can be fetched server-side, you may not need a client-side data fetching library at all — fetch in a React Server Component with Next.js's built-in caching handles a wide range of use cases. The scenarios where TanStack Query and SWR still shine are: client-side data that depends on user interaction, real-time updates, optimistic UI, and cached data that needs to stay fresh across navigation. Apollo Client remains essential for GraphQL regardless of the server component trend.
For related tools, see TanStack Query v5 Migration Guide and Best GraphQL Clients for React 2026.
Comparison Table
| Dimension | TanStack Query v5 | SWR v2 | Apollo Client v3 |
|---|---|---|---|
| Weekly Downloads | 12.3M | 4.9M | 1.4M |
| GitHub Stars | 48K | 32K | 47.4K |
| Bundle Size (gzipped) | 13.4 KB | 4.2 KB | ~47 KB |
| API Type | REST / any | REST / any | GraphQL |
| Cache Strategy | Query-key based | URL/key based | Normalized entity cache |
| Mutations |
useMutation hook |
useSWRMutation |
useMutation |
| Optimistic Updates | Built-in | Manual | Built-in |
| Subscriptions | No (use websockets) | No | Yes |
| Devtools | Excellent | Basic | Good |
| SSR/Next.js | Yes (Hydration) | Native | Yes |
| Offline support | Yes | Limited | Yes |
TanStack Query v5
TanStack Query (formerly React Query) is the most fully-featured server state management library for React. v5 shipped with a more consistent API, improved TypeScript generics, and the new suspense mode built into the core hooks.
interface User {
id: string;
name: string;
email: string;
}
// Basic query
function UserProfile({ userId }: { userId: string }) {
const { data, isPending, isError, error } = useQuery({
queryKey: ["user", userId],
queryFn: () => fetch(`/api/users/${userId}`).then((r) => r.json()),
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes (formerly cacheTime)
});
if (isPending) return <Spinner />;
if (isError) return <Error message={error.message} />;
return <div>{data.name}</div>;
}
// Mutation with optimistic updates
function UpdateUserForm({ user }: { user: User }) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (update: Partial<User>) =>
fetch(`/api/users/${user.id}`, {
method: "PATCH",
body: JSON.stringify(update),
headers: { "Content-Type": "application/json" },
}).then((r) => r.json()),
onMutate: async (update) => {
// Cancel in-flight queries for this user
await queryClient.cancelQueries({ queryKey: ["user", user.id] });
// Snapshot current state for rollback
const previous = queryClient.getQueryData(["user", user.id]);
// Optimistically update
queryClient.setQueryData(["user", user.id], (old: User) => ({ ...old, ...update }));
return { previous };
},
onError: (_err, _update, context) => {
// Roll back on error
queryClient.setQueryData(["user", user.id], context?.previous);
},
onSettled: () => {
// Always refetch after mutation
queryClient.invalidateQueries({ queryKey: ["user", user.id] });
},
});
return (
<button onClick={() => mutation.mutate({ name: "New Name" })}>
{mutation.isPending ? "Saving..." : "Update Name"}
</button>
);
}
Pagination and infinite scroll are first-class features:
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ["posts"],
queryFn: ({ pageParam }) => fetchPosts({ page: pageParam, limit: 20 }),
initialPageParam: 1,
getNextPageParam: (lastPage, pages) =>
lastPage.hasMore ? pages.length + 1 : undefined,
});
TanStack Query's 60% year-over-year download growth tells its own story. The v5 API improvements — unified isPending status, consistent generics, improved suspense integration — removed several longstanding rough edges. Developers who tried earlier versions and found them confusing often report that v5 is significantly cleaner.
The devtools are TanStack Query's most underrated feature. The floating devtools panel shows every active query, its cache status, stale/fresh state, last-updated timestamp, and the data itself. When you're debugging why a component is showing stale data, or why a mutation didn't invalidate the right cache entries, the devtools make the answer immediately visible. SWR's devtools are minimal and Apollo's are good but slower to navigate.
When TanStack Query is the right choice:
- REST or tRPC APIs with complex mutation patterns and cache coordination
- Applications needing robust pagination, infinite scroll, or background refetch
- Teams that want the best devtools and debugging experience
- Projects where TypeScript inference quality and type safety are priorities
- Applications with optimistic updates across multiple related queries
SWR
SWR (stale-while-revalidate) is Vercel's data fetching library. It follows the HTTP cache control strategy: return cached data immediately, then revalidate in the background. The API is intentionally minimal.
const fetcher = (url: string) => fetch(url).then((r) => r.json());
// Basic fetch — as simple as it gets
function UserProfile({ userId }: { userId: string }) {
const { data, error, isLoading } = useSWR(`/api/users/${userId}`, fetcher, {
revalidateOnFocus: true,
revalidateOnReconnect: true,
refreshInterval: 0, // Disable polling
dedupingInterval: 2000,
});
if (isLoading) return <Spinner />;
if (error) return <Error />;
return <div>{data.name}</div>;
}
// Mutations with useSWRMutation (v2)
function UpdateUserButton({ userId }: { userId: string }) {
const { trigger, isMutating } = useSWRMutation(
`/api/users/${userId}`,
async (url: string, { arg }: { arg: Partial<User> }) => {
return fetch(url, {
method: "PATCH",
body: JSON.stringify(arg),
headers: { "Content-Type": "application/json" },
}).then((r) => r.json());
}
);
return (
<button onClick={() => trigger({ name: "New Name" })} disabled={isMutating}>
{isMutating ? "Saving..." : "Update"}
</button>
);
}
SWR's focus on simplicity is genuine, not just marketing. The hook API is as small as it can be while still being useful. The stale-while-revalidate strategy means cached data is returned immediately (no loading flash) while a background request updates it silently — this produces a noticeably snappier UX for repeat visits to data-heavy pages.
SWR's global config and key-based revalidation are its most ergonomic features:
// Global config wraps your app
function App() {
return (
);
}
// Manual revalidation from anywhere
function RefreshButton() {
const { mutate } = useSWRConfig();
return <button onClick={() => mutate("/api/users/me")}>Refresh Profile</button>;
}
SWR's 4.2 KB bundle is its headline advantage. For Next.js apps with simple data needs — user profiles, settings pages, dashboard widgets — SWR is often sufficient and adds minimal overhead. When the data fetching pattern is "fetch this URL and display the result, refresh it occasionally," SWR does that job with the minimum possible code and bundle footprint.
Where SWR shows its limits is in complex mutations. useSWRMutation handles basic cases, but coordinating optimistic updates across multiple cache keys, rolling back failed mutations, and maintaining consistency between related queries requires significantly more manual work than with TanStack Query. Teams that start with SWR often find themselves writing custom cache management code that recreates what TanStack Query provides out of the box. If you're building something with heavy writes, compare the mutation examples carefully before choosing SWR.
When SWR is the right choice:
- Next.js applications with straightforward server-state needs and limited mutations
- Projects where bundle size is a hard constraint and 4 KB vs 13 KB is material
- Simple GET-heavy interfaces like dashboards, profiles, and settings pages
- Vercel-deployed apps where SWR's native Next.js integration is a team preference
Apollo Client
Apollo Client is purpose-built for GraphQL. Its normalized InMemoryCache is the technology that justifies its larger bundle (~47 KB gzipped): every entity returned from any query is stored by __typename + id. When two queries return the same user, one cache entry exists, and updates propagate automatically.
const client = new ApolloClient({
uri: "/graphql",
cache: new InMemoryCache({
typePolicies: {
User: {
fields: {
posts: {
merge(existing = [], incoming) {
return [...existing, ...incoming];
},
},
},
},
},
}),
});
// Query
const GET_USER = gql`
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
posts {
id
title
createdAt
}
}
}
`;
function UserProfile({ userId }: { userId: string }) {
const { data, loading, error } = useQuery(GET_USER, {
variables: { id: userId },
});
if (loading) return <Spinner />;
if (error) return <Error message={error.message} />;
return <div>{data.user.name}</div>;
}
// Mutation with automatic cache update
const UPDATE_USER = gql`
mutation UpdateUser($id: ID!, $name: String!) {
updateUser(id: $id, name: $name) {
id
name
}
}
`;
function UpdateUserForm({ userId }: { userId: string }) {
const [updateUser, { loading }] = useMutation(UPDATE_USER, {
// Apollo automatically updates cache for matching __typename + id
update(cache, { data }) {
cache.modify({
id: cache.identify({ __typename: "User", id: userId }),
fields: {
name: () => data.updateUser.name,
},
});
},
});
return (
<button
onClick={() => updateUser({ variables: { id: userId, name: "New Name" } })}
disabled={loading}
>
Update
</button>
);
}
Apollo's normalized cache is worth understanding at a deeper level because it's the feature that most distinguishes it. In a social app, the same User object might appear in 15 different queries — the current user's profile, a list of followers, a comment author, a post author. In TanStack Query, each query stores its own copy of that user. Update the user's name in one mutation, and you need to invalidate all 15 queries to get consistent data. In Apollo, there's one copy of the User object keyed by User:123. Update it in one mutation, and every query that includes that user reflects the change instantly, without re-fetching. For apps with dense entity graphs — social networks, project management tools, e-commerce catalogs — this is a material architectural advantage.
Real-time subscriptions are Apollo's exclusive advantage among the three:
const MESSAGE_SUBSCRIPTION = gql`
subscription OnNewMessage($channelId: ID!) {
messageAdded(channelId: $channelId) {
id
content
author { id name }
createdAt
}
}
`;
function MessageFeed({ channelId }: { channelId: string }) {
const { data } = useSubscription(MESSAGE_SUBSCRIPTION, {
variables: { channelId },
});
return <div>{data?.messageAdded?.content}</div>;
}
Apollo's 47 KB bundle is the library's main disadvantage for performance-sensitive applications. For mobile web apps where First Contentful Paint matters, 47 KB is significant. If you're using GraphQL but don't need subscriptions or normalized caching, urql is worth considering — it's ~18 KB and supports both document caching and normalized caching via a plugin. But if you've committed to Apollo Server and Apollo Studio on the backend, staying in the Apollo ecosystem for the client has real benefits in tooling coherence.
When Apollo Client is the right choice:
- GraphQL APIs with complex entity relationships where normalized caching provides real value
- Applications requiring real-time subscriptions over WebSocket or SSE
- Teams where the Apollo ecosystem (Apollo Server, Apollo Studio, Apollo Federation) is already in use
- Projects with large shared entity sets where cache normalization prevents stale data problems
When to Choose Each
Choose TanStack Query as your default for REST and tRPC applications. It handles the full lifecycle — background refetch, optimistic updates, pagination, and mutations — better than any competitor. The 13 KB bundle overhead is well justified by what you get. At 12M+ weekly downloads and 48K GitHub stars, it's also the industry standard in 2026, meaning you'll find tutorials, Stack Overflow answers, and team members who know it.
Choose SWR for simpler Next.js apps where bundle size matters and your mutation patterns don't require TanStack Query's full feature set. SWR's stale-while-revalidate pattern is perfectly suited for most dashboard and profile page patterns. If the primary data fetching concern is "keep this page fresh while the user navigates," SWR is elegant and minimal.
Choose Apollo Client when GraphQL is your primary API and you have complex entity relationships that benefit from normalized caching. Don't use Apollo for REST — TanStack Query is significantly better suited. If you need GraphQL but want a smaller bundle, evaluate urql as an alternative before defaulting to Apollo.
The React Server Component wildcard: As Next.js App Router and server components mature, some teams are finding they need client-side data fetching libraries for fewer queries. Server components handle the initial data load; client components use TanStack Query or SWR only for mutations and real-time updates. This hybrid approach is gaining traction and affects the relative importance of bundle size — if only 20% of queries happen on the client, a 13 KB vs 4 KB difference is less significant than it was in the pages-router era.
Cache Invalidation: The Critical Difference
Cache invalidation is the hardest problem in data fetching libraries, and the three tools approach it very differently. Understanding this difference often clarifies the selection decision.
TanStack Query uses explicit cache key invalidation: queryClient.invalidateQueries({ queryKey: ["user", userId] }) marks a query stale and triggers a background refetch. This is manual but predictable — you control exactly what refetches when. The query key structure is your cache key design, and good key design makes invalidation straightforward.
SWR uses URL-based keys and provides mutate(key) for invalidation. The simplicity is the point — most SWR apps use a URL as the key, so invalidating after a mutation to /api/users/123 means calling mutate("/api/users/123"). This works naturally for simple cases but becomes awkward when multiple different queries might return the same resource under different keys.
Apollo's normalized cache uses entity-level invalidation. You don't invalidate queries — you modify entities in the cache and Apollo propagates those changes to every query that included that entity. This is more sophisticated but also more opaque: when something doesn't update correctly, debugging requires understanding the cache's normalization logic. Apollo's cache modification API (cache.modify, cache.evict) is powerful but has a steeper learning curve than TanStack Query's query invalidation.
Methodology
Download statistics from npm trends and TanStack's own npm stats page (March 2026). Bundle sizes from Bundlephobia. GitHub stars from repository pages. Feature comparison based on official documentation for TanStack Query v5.90, SWR v2.3, and Apollo Client v3.12. Benchmark data on TanStack Query growth sourced from TanStack official npm statistics dashboard.
Top comments (0)