DevToolBoxFREE
Blog

GraphQL Client Guide: Apollo Client, Caching, Pagination, Code Generation, urql & Relay

22 min readby DevToolBox Team

TL;DR

GraphQL clients handle query execution, caching, and state management for your frontend. Apollo Client is the most feature-rich option with normalized caching and DevTools. urql offers a lighter plugin-based architecture. Relay excels at large-scale apps with its compiler-driven approach. Choose based on your project size, team experience, and performance requirements.

Key Takeaways

  • Apollo Client provides normalized caching, optimistic UI, and the richest ecosystem of any GraphQL client.
  • useQuery and useMutation hooks abstract away loading, error, and refetch logic for clean component code.
  • Cache normalization stores entities by ID, enabling automatic UI updates when data changes anywhere.
  • Cursor-based pagination with fetchMore and relay-style connections scales better than offset pagination.
  • graphql-codegen generates TypeScript types from your schema for end-to-end type safety.
  • WebSocket subscriptions enable real-time features like live notifications and chat.
  • urql is a compelling alternative for simpler projects with its exchange-based plugin system.
  • Rate limiting, depth limiting, and query complexity analysis protect your GraphQL API from abuse.

1. GraphQL Fundamentals

GraphQL is a query language for APIs that lets clients request exactly the data they need. It supports three operation types: queries for reading data, mutations for writing data, and subscriptions for real-time updates. Unlike REST, a single GraphQL endpoint handles all operations, and the client specifies the response shape.

# Query — fetch user with nested posts
query GetUser($id: ID!) {
  user(id: $id) {
    name
    email
    posts(first: 5) {
      title
      createdAt
    }
  }
}

# Mutation — create a new post
mutation CreatePost($input: PostInput!) {
  createPost(input: $input) {
    id
    title
  }
}

2. Apollo Client Setup

Apollo Client is the most popular GraphQL client for React. It combines an intelligent normalized cache, a composable link chain for request processing, and built-in error handling. The link chain lets you add authentication headers, log errors, and retry failed requests in a modular way.

import {
  ApolloClient, InMemoryCache,
  createHttpLink, from
} from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import { onError } from '@apollo/client/link/error';

const httpLink = createHttpLink({
  uri: '/graphql',
});
const authLink = setContext((_, { headers }) => ({
  headers: {
    ...headers,
    authorization: localStorage.getItem('token')
      ? 'Bearer ' + localStorage.getItem('token')
      : '',
  },
}));
const errorLink = onError(({ graphQLErrors }) => {
  if (graphQLErrors) graphQLErrors.forEach(e =>
    console.error('[GQL]', e.message));
});
export const client = new ApolloClient({
  link: from([errorLink, authLink, httpLink]),
  cache: new InMemoryCache(),
});

3. useQuery & useMutation

Apollo provides React hooks for declarative data fetching. useQuery handles loading states, errors, polling, and refetching automatically. useMutation returns a trigger function and tracks the mutation lifecycle. Both hooks integrate with the cache so your UI updates reactively.

import { useQuery, useMutation, gql } from '@apollo/client';

const GET_TODOS = gql`
  query { todos { id text done } }
`;
const TOGGLE = gql`
  mutation Toggle($id: ID!) {
    toggleTodo(id: $id) { id done }
  }
`;

function TodoList() {
  const { data, loading, error } = useQuery(GET_TODOS);
  const [toggle] = useMutation(TOGGLE, {
    optimisticResponse: ({ id }) => ({
      toggleTodo: { __typename: 'Todo', id, done: true },
    }),
  });
  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error</p>;
  return data.todos.map(t => (
    <li key={t.id} onClick={() => toggle({ variables: { id: t.id } })}>
      {t.text} {t.done ? '(done)' : ''}
    </li>
  ));
}

4. Cache Management

Apollo InMemoryCache uses normalized caching, splitting query results into individual objects stored by typename and ID. This means updating a user object in one query automatically updates it everywhere. Type policies let you customize how fields are read, merged, and keyed.

const cache = new InMemoryCache({
  typePolicies: {
    User: {
      keyFields: ['email'], // use email as cache key
    },
    Query: {
      fields: {
        posts: {
          keyArgs: ['category'],
          merge(existing = [], incoming) {
            return [...existing, ...incoming];
          },
          read(existing) {
            return existing;
          },
        },
      },
    },
  },
});

// Manual cache update after mutation
cache.modify({
  id: cache.identify({ __typename: 'User', email: 'a@b.com' }),
  fields: {
    postCount(prev) { return prev + 1; },
  },
});

5. Pagination

Cursor-based pagination is the recommended approach for GraphQL. It uses an opaque cursor to mark positions in a list, avoiding the issues with offset-based pagination when items are inserted or deleted. The Relay connection specification provides a standard pattern with edges, nodes, and pageInfo.

const GET_FEED = gql`
  query Feed($cursor: String) {
    feed(first: 10, after: $cursor) {
      edges { node { id title } cursor }
      pageInfo { hasNextPage endCursor }
    }
  }
`;

function Feed() {
  const { data, fetchMore } = useQuery(GET_FEED);
  const loadMore = () => fetchMore({
    variables: {
      cursor: data.feed.pageInfo.endCursor,
    },
  });
  return (
    <div>
      {data?.feed.edges.map(e => (
        <p key={e.node.id}>{e.node.title}</p>
      ))}
      {data?.feed.pageInfo.hasNextPage && (
        <button onClick={loadMore}>Load More</button>
      )}
    </div>
  );
}

6. Code Generation

graphql-codegen reads your schema and operations to generate TypeScript types, typed hooks, and document nodes. This eliminates manual type definitions and catches type errors at build time. The typed-document-node plugin generates zero-runtime-overhead types that work with any GraphQL client.

# codegen.yml
schema: 'http://localhost:4000/graphql'
documents: 'src/**/*.graphql'
generates:
  src/generated/graphql.ts:
    plugins:
      - typescript
      - typescript-operations
      - typed-document-node

# Usage with generated types
# src/queries/user.graphql
# query GetUser($id: ID!) {
#   user(id: $id) { id name email }
# }

# In component — fully typed, no manual types
# import { GetUserDocument } from './generated/graphql';
# const { data } = useQuery(GetUserDocument, {
#   variables: { id: '1' }, // type-checked
# });
# data?.user.name; // autocomplete works

7. Subscriptions

GraphQL subscriptions use WebSocket connections to push real-time updates from the server. Apollo Client supports subscriptions through graphql-ws, maintaining a persistent connection. You can use useSubscription for standalone listeners or subscribeToMore to append real-time data to existing queries.

import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';
import { split, HttpLink } from '@apollo/client';
import { getMainDefinition } from '@apollo/client/utilities';

const wsLink = new GraphQLWsLink(
  createClient({ url: 'ws://localhost:4000/graphql' })
);
const httpLink = new HttpLink({ uri: '/graphql' });
const splitLink = split(
  ({ query }) => {
    const def = getMainDefinition(query);
    return def.kind === 'OperationDefinition'
      && def.operation === 'subscription';
  },
  wsLink, httpLink,
);

// useSubscription hook
// const { data } = useSubscription(ON_MESSAGE);

8. Error Handling

GraphQL can return partial data alongside errors, unlike REST which typically returns a single status code. Apollo errorPolicy controls this behavior: "none" throws on errors, "all" delivers both data and errors, and "ignore" silently discards errors. Combine error links with React error boundaries for robust handling.

import { onError } from '@apollo/client/link/error';
import { RetryLink } from '@apollo/client/link/retry';

const retryLink = new RetryLink({
  delay: { initial: 300, max: 3000, jitter: true },
  attempts: { max: 3, retryIf: (error) => !!error },
});

const errorLink = onError(
  ({ graphQLErrors, networkError, operation }) => {
    if (graphQLErrors) {
      for (const err of graphQLErrors) {
        if (err.extensions?.code === 'UNAUTHENTICATED') {
          // redirect to login
          window.location.href = '/login';
        }
      }
    }
    if (networkError) {
      console.error('Network error on', operation.operationName);
    }
  }
);
// link chain: retryLink -> errorLink -> httpLink

9. Testing GraphQL Components

Apollo provides MockedProvider to wrap components with predetermined query responses during tests. You can mock loading states, errors, and successful responses without hitting a real server. For hook-level testing, renderHook from testing-library works with Apollo mocks.

import { MockedProvider } from '@apollo/client/testing';
import { render, screen, waitFor } from '@testing-library/react';

const mocks = [{
  request: {
    query: GET_TODOS,
    variables: {},
  },
  result: {
    data: {
      todos: [
        { id: '1', text: 'Write tests', done: false },
        { id: '2', text: 'Ship code', done: true },
      ],
    },
  },
}];

test('renders todos', async () => {
  render(
    <MockedProvider mocks={mocks} addTypename={false}>
      <TodoList />
    </MockedProvider>
  );
  await waitFor(() => {
    expect(screen.getByText('Write tests')).toBeTruthy();
  });
});

10. Performance Optimization

Apollo supports query batching to combine multiple operations into a single HTTP request, reducing network overhead. Persisted queries send a hash instead of the full query string, shrinking request size and enabling CDN caching. Deferred queries with @defer let you progressively render slow-to-resolve fields.

import { BatchHttpLink } from '@apollo/client/link/batch-http';
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { sha256 } from 'crypto-hash';

// Batch multiple queries into one request
const batchLink = new BatchHttpLink({
  uri: '/graphql',
  batchMax: 5,
  batchInterval: 20,
});

// Persisted queries — send hash instead of query
const persistedLink = createPersistedQueryLink({
  sha256, useGETForHashedQueries: true,
});

// @defer for progressive loading
// query GetPost($id: ID!) {
//   post(id: $id) {
//     title
//     body
//     ... @defer { comments { id text } }
//   }
// }

11. urql Alternative

urql is a lightweight GraphQL client with a plugin system based on exchanges. It ships at roughly half the bundle size of Apollo and uses a simpler document cache by default. Custom exchanges let you add normalized caching, authentication, retry logic, and more. urql is a strong choice when you want a thinner abstraction.

import { Client, cacheExchange, fetchExchange, Provider }
  from 'urql';
import { authExchange } from '@urql/exchange-auth';

const client = new Client({
  url: '/graphql',
  exchanges: [
    cacheExchange,
    authExchange(async (utils) => ({
      addAuthToOperation(operation) {
        const token = localStorage.getItem('token');
        return token ? utils.appendHeaders(operation, {
          Authorization: 'Bearer ' + token,
        }) : operation;
      },
      didAuthError(error) {
        return error.graphQLErrors.some(
          e => e.extensions?.code === 'UNAUTHORIZED'
        );
      },
      async refreshAuth() {
        localStorage.removeItem('token');
        window.location.href = '/login';
      },
    })),
    fetchExchange,
  ],
});

12. Relay Modern

Relay is Facebook's GraphQL client, designed for large-scale applications. Its compiler statically analyzes your queries at build time, generating optimized runtime artifacts. Relay enforces data masking, meaning components can only access the data they explicitly request via fragments. This guarantees no implicit data dependencies.

// UserProfile.tsx — Relay fragment component
import { graphql, useFragment } from 'react-relay';

const UserProfileFragment = graphql`
  fragment UserProfile_user on User {
    name
    avatar
    bio
    followerCount
  }
`;

function UserProfile({ userRef }) {
  const data = useFragment(UserProfileFragment, userRef);
  return (
    <div>
      <img src={data.avatar} alt={data.name} />
      <h2>{data.name}</h2>
      <p>{data.bio}</p>
      <span>{data.followerCount} followers</span>
    </div>
  );
}

13. GraphQL Security

GraphQL's flexible query language means clients can craft deeply nested or expensive queries. Protecting your API requires depth limiting (rejecting queries deeper than N levels), query complexity analysis (assigning costs to fields), and rate limiting (throttling by client or IP). Disable introspection in production to prevent schema leakage.

import depthLimit from 'graphql-depth-limit';
import { createComplexityLimitRule }
  from 'graphql-validation-complexity';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [
    depthLimit(7),
    createComplexityLimitRule(1000, {
      scalarCost: 1,
      objectCost: 5,
      listFactor: 10,
    }),
  ],
  introspection: process.env.NODE_ENV !== 'production',
});

// Rate limiting with express middleware
// app.use('/graphql', rateLimit({
//   windowMs: 60 * 1000,
//   max: 100,
// }));

Try our related developer tools

FAQ

Should I use Apollo Client or urql for a new project?

If you need normalized caching, optimistic UI, subscription support, and a large ecosystem, choose Apollo Client. If you prefer a smaller bundle, simpler API, and are okay with document-level caching, choose urql. For large-scale apps with strict data access patterns, consider Relay.

How does GraphQL caching differ from REST caching?

REST uses HTTP caching headers and CDN caching per endpoint. GraphQL clients implement application-level caching. Apollo normalizes data by typename and ID so updating one entity updates all queries referencing it. This is more powerful than HTTP caching but requires careful cache policy configuration.

Is graphql-codegen worth the setup effort?

Yes. Code generation provides full TypeScript types from your schema, catches type errors at build time, generates typed hooks, and eliminates manual type maintenance. The initial setup takes about 15 minutes and saves significant debugging time as your project grows.

How do I handle authentication in Apollo Client?

Use a setContext link to read auth tokens from storage and attach them as Authorization headers on every request. For token refresh, use an error link that catches 401 errors, refreshes the token, and retries the failed operation using forward().

When should I use subscriptions vs polling?

Use subscriptions for truly real-time data like chat messages or live dashboards where low latency matters. Use polling (useQuery with pollInterval) for data that updates every few seconds and where a slight delay is acceptable. Polling is simpler to implement and scale.

How do I prevent N+1 queries on the server?

Use DataLoader to batch and deduplicate database requests within a single GraphQL operation. DataLoader collects all IDs requested in a single tick, then executes one batched query. Initialize a new DataLoader per request to avoid caching across users.

Can I use GraphQL without a dedicated client library?

Yes. You can use fetch or graphql-request to send queries as plain HTTP POST requests. However, you lose automatic caching, optimistic updates, and subscription support. A dedicated client is recommended for any app beyond simple data fetching.

How do I secure a GraphQL API in production?

Implement depth limiting, query complexity analysis, and rate limiting. Disable introspection in production. Use persisted queries to whitelist allowed operations. Add authentication and field-level authorization. Monitor query performance and set timeout limits on resolvers.

𝕏 Twitterin LinkedIn
Was this helpful?

Stay Updated

Get weekly dev tips and new tool announcements.

No spam. Unsubscribe anytime.

Try These Related Tools

GTGraphQL to TypeScript{ }JSON Formatter📜OpenAPI to TypeScript

Related Articles

Advanced GraphQL Guide: Schema Design, Resolvers, Subscriptions, Federation & Performance

Comprehensive advanced GraphQL guide covering schema design, custom scalars, directives, resolver patterns with DataLoader, subscriptions, Apollo Federation, authentication, caching, pagination, testing, and monitoring.

React Design Patterns Guide: Compound Components, Custom Hooks, HOC, Render Props & State Machines

Complete React design patterns guide covering compound components, render props, custom hooks, higher-order components, provider pattern, state machines, controlled vs uncontrolled, composition, observer pattern, error boundaries, and module patterns.

Advanced TypeScript Guide: Generics, Conditional Types, Mapped Types, Decorators, and Type Narrowing

Master advanced TypeScript patterns. Covers generic constraints, conditional types with infer, mapped types (Partial/Pick/Omit), template literal types, discriminated unions, utility types deep dive, decorators, module augmentation, type narrowing, covariance/contravariance, and satisfies operator.