Best GraphQL Clients for React in 2026
The GraphQL Client Decision: More Than Just Bundle Size
Choosing a GraphQL client for React in 2026 involves tradeoffs that go beyond download counts and bundle sizes. The fundamental architectural question is caching strategy: how does the client store and update the data it fetches? The answer to this question determines which library fits your application's data access patterns.
Apollo Client pioneered normalized caching for GraphQL. A normalized cache stores every object by its ID and type, regardless of which query fetched it. When you query user(id: "123") in one component and later query users (which includes that same user) in another, Apollo Client recognizes they're the same entity and keeps the cached data consistent. When a mutation updates user(id: "123"), every component that displays that user re-renders automatically — without any explicit cache update code.
urql's default caching strategy is simpler: the document cache stores results keyed by query + variables. It's predictable and easy to reason about, but doesn't provide the cross-query consistency that normalized caching enables. urql does offer normalized caching via the @urql/exchange-graphcache package — this is the opt-in path for teams that start simple and need more sophistication later.
TanStack Query takes a completely different approach: it's not a GraphQL client at all. It's a data fetching and caching library that works with any async function — REST, GraphQL, or anything else. For GraphQL, you provide a fetch function that sends the query, and TanStack Query handles caching, refetching, background updates, and loading/error states. You lose GraphQL-aware cache normalization but gain the flexibility to incrementally adopt GraphQL in a codebase that's primarily REST.
TL;DR
Apollo Client for complex apps with normalized caching; urql for lightweight needs; TanStack Query + fetch for REST-first teams adding GraphQL. Apollo Client (~2M weekly downloads) is the most feature-complete but heaviest option. urql (~600K downloads) is modular and 3-4x smaller. TanStack Query (~5M downloads) handles GraphQL as plain async functions — no schema awareness but maximum flexibility.
Key Takeaways
- Apollo Client: ~2M weekly downloads — full-featured normalized cache, 47KB gzipped
- urql: ~600K downloads — modular, document cache, ~14KB gzipped
- TanStack Query: ~5M downloads — framework-agnostic, works with any fetcher
- Normalized cache vs document cache — Apollo stores by ID; urql stores by query
- GraphQL Code Generator — pairs with any client for type-safe operations
Apollo Client (Full-Featured)
Apollo Client is the most widely adopted GraphQL client in the React ecosystem, and its feature set reflects years of investment in solving the hardest problems in client-side GraphQL: cache invalidation, optimistic updates, pagination, and real-time subscriptions. The 47KB gzipped bundle size is larger than alternatives, but it includes a substantial amount of functionality that would otherwise require additional packages.
The normalized InMemoryCache is Apollo's signature capability. By default, Apollo identifies objects using their __typename + id (or the keyFields you configure). This means that data from different queries that refers to the same entity is automatically kept in sync. Mutations that return updated objects automatically propagate those updates to every query that cached the affected entity. This cross-query consistency is what makes Apollo Client particularly well-suited to complex applications where the same data appears in multiple places — user profiles shown in a sidebar, a list view, and a detail page, for example.
Apollo Client's ecosystem also includes Apollo Studio (a cloud platform for schema management, performance monitoring, and usage tracking), Apollo DevTools for Chrome, and extensive integration with React's Suspense. For enterprise teams managing large GraphQL schemas across multiple services, the combination of the client library and the Apollo Studio platform provides observability that the other options don't match.
// Apollo Client — normalized cache, optimistic updates
import { ApolloClient, InMemoryCache, gql, useQuery, useMutation } from '@apollo/client';
const client = new ApolloClient({
uri: 'https://api.example.com/graphql',
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
users: {
// Merge paginated results
keyArgs: ['filter'],
merge(existing = [], incoming) {
return [...existing, ...incoming];
},
},
},
},
},
}),
});
// Query with caching
const GET_USER = gql`
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
posts { id title }
}
}
`;
function UserProfile({ userId }: { userId: string }) {
const { data, loading, error } = useQuery(GET_USER, {
variables: { id: userId },
// Apollo checks normalized cache — no network request if user already fetched
});
if (loading) return <Spinner />;
if (error) return <Error message={error.message} />;
return <div>{data.user.name}</div>;
}
// Apollo — optimistic updates (UI updates before server responds)
const UPDATE_USER = gql`
mutation UpdateUser($id: ID!, $name: String!) {
updateUser(id: $id, name: $name) {
id
name
}
}
`;
function EditUserForm({ user }) {
const [updateUser] = useMutation(UPDATE_USER, {
optimisticResponse: {
updateUser: {
__typename: 'User',
id: user.id,
name: 'New Name', // Show immediately
},
},
});
return (
<button onClick={() => updateUser({ variables: { id: user.id, name: 'New Name' } })}>
Update (shows instantly, syncs in background)
</button>
);
}
Best for: Apps with complex data graphs, cross-component cache sharing, optimistic updates.
urql (Lightweight, Modular)
urql's design philosophy is the opposite of Apollo Client's: start with the minimum, add capabilities as you need them. The core package is ~14KB gzipped. The cacheExchange that ships with the core package uses a document cache — simple, fast, and correct for applications where queries don't share overlapping data. When you need normalized caching, the @urql/exchange-graphcache package adds it as an optional layer.
The exchange system is urql's most distinctive architectural feature. Exchanges are middleware that form a pipeline between your application and the network. The default pipeline is cacheExchange → fetchExchange. You can insert additional exchanges anywhere: a devtoolsExchange for debugging, retryExchange for automatic retry on network failure, offlineExchange for offline support, or subscriptionExchange for WebSocket subscriptions. This composability means urql scales from a simple document-caching fetch wrapper to a sophisticated client with normalized caching, offline support, and real-time updates — without the base bundle carrying all that weight by default.
urql's requestPolicy option provides explicit control over when to hit the network vs. return cached data. 'cache-first' is the default, returning cached data immediately and skipping the network if a result exists. 'network-only' always fetches fresh. 'cache-and-network' returns the cached result immediately and then re-fetches in the background, updating the UI when fresh data arrives. This granular control makes it easy to tune behavior per-query.
// urql — composable exchanges (middleware)
import { createClient, cacheExchange, fetchExchange, Provider } from 'urql';
const client = createClient({
url: 'https://api.example.com/graphql',
exchanges: [
cacheExchange, // Document cache (simple, predictable)
fetchExchange, // Network layer
],
});
// Wrap app
function App() {
return (
<Provider value={client}>
<Router />
</Provider>
);
}
// urql hooks — similar to Apollo but lighter
import { useQuery, useMutation } from 'urql';
const GetUserQuery = `
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
}
}
`;
function UserProfile({ userId }: { userId: string }) {
const [result] = useQuery({
query: GetUserQuery,
variables: { id: userId },
// requestPolicy: 'cache-first' | 'network-only' | 'cache-and-network'
requestPolicy: 'cache-first',
});
const { data, fetching, error } = result;
if (fetching) return <Spinner />;
if (error) return <Error message={error.message} />;
return <div>{data?.user.name}</div>;
}
// urql — normalized cache (optional, separate package)
import { createClient } from 'urql';
import { cacheExchange } from '@urql/exchange-graphcache';
const client = createClient({
url: 'https://api.example.com/graphql',
exchanges: [
cacheExchange({
// Optional: define schema for full normalization
keys: {
User: (data) => data.id,
Post: (data) => data.id,
},
updates: {
Mutation: {
createPost(result, args, cache) {
// Manually update cache after mutation
cache.invalidate('Query', 'posts');
},
},
},
}),
fetchExchange,
],
});
Best for: Apps that need GraphQL features without Apollo's overhead. Modular — add normalization when you need it.
TanStack Query + GraphQL
TanStack Query's 5 million weekly downloads make it the most downloaded of the three options by a significant margin, but most of those downloads are for REST API use cases. TanStack Query for GraphQL is a pattern rather than a native integration: you write a function that sends a GraphQL request (using fetch directly or graphql-request as a thin helper) and pass it to useQuery. TanStack Query handles the caching, deduplication, background refetching, and loading/error state management.
The tradeoff is that TanStack Query's cache is keyed by the queryKey you provide (typically ['user', userId]), not by entity identity. Two queries that fetch overlapping data are treated as independent cache entries. This is fine for most applications and the mental model is simple to reason about, but it means you need to manually invalidate the cache after mutations rather than relying on automatic normalization.
Where TanStack Query excels is in incremental GraphQL adoption. If your application already uses TanStack Query for REST endpoints, adding GraphQL queries follows exactly the same pattern — the same hooks, the same DevTools, the same loading state management. There's no separate cache to synchronize, no additional provider, no new API surface to learn. Teams that are migrating incrementally from REST to GraphQL find this a significant practical advantage.
// TanStack Query — GraphQL as plain async functions
// No GraphQL client library needed — just fetch
import { useQuery, useMutation } from '@tanstack/react-query';
const fetchUser = async (userId: string) => {
const response = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
}
}
`,
variables: { id: userId },
}),
});
const { data, errors } = await response.json();
if (errors) throw new Error(errors[0].message);
return data.user;
};
function UserProfile({ userId }: { userId: string }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
if (isLoading) return <Spinner />;
if (error) return <Error message={error.message} />;
return <div>{data?.name}</div>;
}
// TanStack Query — works great with graphql-request
import { request } from 'graphql-request';
import { useQuery } from '@tanstack/react-query';
const GET_USER = `
query GetUser($id: ID!) {
user(id: $id) { id name email }
}
`;
function UserProfile({ userId }: { userId: string }) {
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => request('/graphql', GET_USER, { id: userId }),
});
return <div>{data?.user.name}</div>;
}
Best for: Teams already using TanStack Query for REST who want GraphQL without a second cache layer.
Type Safety with GraphQL Code Generator
All three clients benefit significantly from GraphQL Code Generator, which generates TypeScript types from your GraphQL schema and operations. The generated types match your actual queries: if you query { user { id name } }, the generated type has exactly { user: { id: string; name: string } } — no manually maintained interfaces, no risk of drift between your TypeScript types and what the server actually returns.
The setup is roughly the same regardless of which client you're using. You install @graphql-codegen/cli and a target plugin (@graphql-codegen/typescript-operations for operation types, plus a client-specific plugin like @graphql-codegen/client-preset for Apollo/urql or @graphql-codegen/typescript-react-query for TanStack Query). You configure a codegen.yml pointing at your schema URL and your operation files, then run graphql-codegen to generate types.
The practical impact is substantial: autocompletion for every field in every query, TypeScript errors when you reference a field that doesn't exist, and type-safe variables that prevent sending the wrong type to a mutation. For teams where multiple developers work on the same GraphQL queries, Code Generator essentially eliminates a class of runtime bugs before they reach production.
Apollo Client users also get the benefit of the TypeScript plugin's strict mode, which flags query results as potentially partial (because some fields might be excluded by field-level permissions or aliasing). urql's @urql/introspection plugin enables cache normalization with full schema awareness. TanStack Query users get generated hooks that wrap useQuery with the correct TypeScript generics pre-applied.
Bundle Size Comparison
| Client | Bundle Size (gzip) | Normalized Cache | Real-time | SSR Support |
|---|---|---|---|---|
| Apollo Client | ~47KB | ✅ Built-in | ✅ Subscriptions | ✅ |
| urql + graphcache | ~14KB + 12KB | ✅ Optional | ✅ Subscriptions | ✅ |
| TanStack Query + graphql-request | ~5KB + 5KB | ❌ | ❌ (manual) | ✅ |
| React Query + urql core | Mix | ❌ | ✅ | ✅ |
Server-Side Rendering and React Server Components
All three options support SSR, but their integration patterns differ significantly and the tradeoffs matter for Next.js App Router projects.
Apollo Client has an established SSR story via @apollo/client/testing and ApolloProvider with HttpLink configured to use absolute URLs. For Next.js App Router, Apollo introduced the @apollo/client-react-streaming package and ApolloWrapper patterns that allow pre-fetching in Server Components while keeping the normalized cache hydrated on the client. The setup is more involved than REST-based data fetching, but the full Apollo Client feature set — normalized cache, optimistic updates, subscriptions — is available after hydration.
urql has a ssrExchange that serializes cache state on the server and rehydrates it in the browser. The pattern is well-documented and works with both Pages Router and App Router. For React Server Components specifically, urql's smaller footprint makes it easier to use in mixed environments where you want to keep client bundle size down.
TanStack Query's App Router integration uses HydrationBoundary and dehydrate/hydrate to pass prefetched query results from server to client. This pattern works identically for GraphQL and REST queries, which is an advantage for mixed-data applications. The prefetchQuery pattern in server components colocates the data requirements with the component hierarchy, which is conceptually clean even if the setup involves a few more pieces than a simple REST fetch.
The practical recommendation for new Next.js App Router projects is to start with TanStack Query's hydration pattern if you're doing mixed REST + GraphQL, or urql if you're going GraphQL-first and want a smaller bundle. Apollo Client's full SSR story is solid but adds complexity that's only worth it if you're heavily leveraging normalized caching and optimistic updates.
Subscriptions (Real-time)
// Apollo Client — WebSocket subscriptions
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';
const wsLink = new GraphQLWsLink(createClient({
url: 'wss://api.example.com/graphql',
}));
const MESSAGES_SUBSCRIPTION = gql`
subscription OnMessage($roomId: ID!) {
message(roomId: $roomId) {
id text author { name }
}
}
`;
function ChatRoom({ roomId }) {
const { data } = useSubscription(MESSAGES_SUBSCRIPTION, {
variables: { roomId },
});
// data.message updates automatically
}
// urql — subscriptions via wonka exchanges
import { subscriptionExchange } from 'urql';
import { createClient } from 'graphql-ws';
const wsClient = createClient({ url: 'wss://api.example.com/graphql' });
const client = createClient({
url: 'https://api.example.com/graphql',
exchanges: [
cacheExchange,
subscriptionExchange({
forwardSubscription(request) {
return { subscribe: (sink) => ({ unsubscribe: wsClient.subscribe(request, sink) }) };
},
}),
fetchExchange,
],
});
When to Choose
The tiebreaker for most teams is the existing stack. If you're building a new Next.js application with no existing data fetching setup, urql is the lowest-overhead entry point — smaller bundle, simpler mental model, and you can always add @urql/exchange-graphcache when you hit the limitations of the document cache. If you have an existing TanStack Query setup for REST APIs and you're adding a GraphQL endpoint, reaching for graphql-request alongside TanStack Query is the least disruptive path. Apollo Client makes sense when you're building something large enough that normalized caching's consistency guarantees are worth the bundle size and setup complexity — think of it as the default enterprise choice, where the Apollo Studio integration and the battle-tested normalized cache justify the investment.
The wrong choice to avoid: using Apollo Client in a simple CRUD application because it's the most well-known option. Apollo's complexity pays off when you have cross-component data sharing, optimistic updates, and real-time subscriptions all in play. For a straightforward form-based application that fetches data and updates it, urql with a document cache or TanStack Query is genuinely the better fit — faster to load, simpler to debug, and less boilerplate to maintain.
| Scenario | Pick |
|---|---|
| Complex app with cross-component data sharing | Apollo Client |
| Need normalized cache without Apollo's size | urql + graphcache |
| Team already uses TanStack Query | TanStack Query + fetch/graphql-request |
| Simple GraphQL queries, no real-time | urql (smallest) |
| Subscriptions are core to the app | Apollo Client or urql |
| Multi-framework app (React + RN + Vue) | urql or TanStack Query |
| Enterprise app with Apollo Studio | Apollo Client |
Compare GraphQL client package health on PkgPulse.
See also: React vs Vue and React vs Svelte, Apollo Client vs urql in 2026: GraphQL Client Libraries.
See the live comparison
View apollo client vs. urql on PkgPulse →