Skip to main content

Best GraphQL Clients for React in 2026

·PkgPulse Team

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 — 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 — 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 — 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.


Bundle Size Comparison

ClientBundle Size (gzip)Normalized CacheReal-timeSSR Support
Apollo Client~47KB✅ Built-in✅ Subscriptions
urql + graphcache~14KB + 12KB✅ Optional✅ Subscriptions
TanStack Query + graphql-request~5KB + 5KB❌ (manual)
React Query + urql coreMix

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

ScenarioPick
Complex app with cross-component data sharingApollo Client
Need normalized cache without Apollo's sizeurql + graphcache
Team already uses TanStack QueryTanStack Query + fetch/graphql-request
Simple GraphQL queries, no real-timeurql (smallest)
Subscriptions are core to the appApollo Client or urql
Multi-framework app (React + RN + Vue)urql or TanStack Query
Enterprise app with Apollo StudioApollo Client

Compare GraphQL client package health on PkgPulse.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.