React

GraphQL and Apollo Client in React: From Setup to Production Patterns

A practical guide to building React applications with GraphQL and Apollo Client — covering schema design, queries, mutations, caching strategies, optimistic updates, and real-world performance patterns.

Sameer Sabir
Updated:
13 min read
GraphQLApollo ClientReactTypeScriptAPICaching

GraphQL and Apollo Client in React: From Setup to Production Patterns

REST APIs have served us well, but for complex data requirements — nested relationships, real-time updates, and efficient data fetching — GraphQL is a superior choice. Having worked extensively with GraphQL and Apollo Client on the Procurement League platform, I've learned what works (and what doesn't) when building production GraphQL-powered React applications.

Why GraphQL Over REST?

FeatureRESTGraphQL
Data fetchingMultiple endpointsSingle endpoint
Over-fetchingCommonEliminated
Under-fetchingRequires multiple callsSolved with nested queries
TypingExternal schemasBuilt-in type system
Real-timeWebSockets (separate)Subscriptions (native)
CachingManual, complexAutomatic with Apollo

Project Setup

Install Dependencies

npm install @apollo/client graphql
npm install -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/typescript-react-apollo

Apollo Client Configuration

// lib/apollo-client.ts
import { ApolloClient, InMemoryCache, HttpLink, from } from '@apollo/client';
import { onError } from '@apollo/client/link/error';
import { RetryLink } from '@apollo/client/link/retry';

const httpLink = new HttpLink({
  uri: process.env.NEXT_PUBLIC_GRAPHQL_URL,
  credentials: 'include',
});

const errorLink = onError(({ graphQLErrors, networkError, operation }) => {
  if (graphQLErrors) {
    graphQLErrors.forEach(({ message, locations, path }) => {
      console.error(
        `[GraphQL Error]: Message: ${message}, Location: ${locations}, Path: ${path}`
      );
    });
  }

  if (networkError) {
    console.error(`[Network Error]: ${networkError.message}`);
    // Optionally redirect to error page or show toast
  }
});

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

export const apolloClient = new ApolloClient({
  link: from([errorLink, retryLink, httpLink]),
  cache: new InMemoryCache({
    typePolicies: {
      Query: {
        fields: {
          products: {
            keyArgs: ['category', 'search'],
            merge(existing = [], incoming) {
              return [...existing, ...incoming];
            },
          },
        },
      },
      Product: {
        keyFields: ['id'],
      },
    },
  }),
  defaultOptions: {
    watchQuery: {
      fetchPolicy: 'cache-and-network',
      errorPolicy: 'all',
    },
    query: {
      fetchPolicy: 'cache-first',
      errorPolicy: 'all',
    },
  },
});

Apollo Provider Setup

// app/providers.tsx
'use client';

import { ApolloProvider } from '@apollo/client';
import { apolloClient } from '@/lib/apollo-client';

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ApolloProvider client={apolloClient}>
      {children}
    </ApolloProvider>
  );
}

Code Generation for Type Safety

Auto-generate TypeScript types from your GraphQL schema:

# codegen.ts
import type { CodegenConfig } from '@graphql-codegen/cli';

const config: CodegenConfig = {
  overwrite: true,
  schema: 'http://localhost:4000/graphql',
  documents: 'src/**/*.graphql',
  generates: {
    'src/generated/graphql.ts': {
      plugins: [
        'typescript',
        'typescript-operations',
        'typescript-react-apollo',
      ],
      config: {
        withHooks: true,
        withComponent: false,
        withHOC: false,
      },
    },
  },
};

export default config;
npx graphql-codegen --config codegen.ts

Queries: Fetching Data Efficiently

Basic Query with Generated Hooks

# src/graphql/queries/products.graphql
query GetProducts($category: String, $limit: Int, $offset: Int) {
  products(category: $category, limit: $limit, offset: $offset) {
    id
    name
    price
    description
    category {
      id
      name
    }
    images {
      url
      alt
    }
    rating
    reviewCount
  }
}

query GetProductById($id: ID!) {
  product(id: $id) {
    id
    name
    price
    description
    specifications {
      key
      value
    }
    category {
      id
      name
    }
    images {
      url
      alt
    }
    reviews {
      id
      author
      rating
      comment
      createdAt
    }
  }
}

Using Generated Hooks in Components

// components/ProductList.tsx
'use client';

import { useGetProductsQuery } from '@/generated/graphql';
import { useState } from 'react';

export const ProductList: React.FC = () => {
  const [category, setCategory] = useState<string | null>(null);

  const { data, loading, error, fetchMore } = useGetProductsQuery({
    variables: {
      category,
      limit: 12,
      offset: 0,
    },
    notifyOnNetworkStatusChange: true,
  });

  if (error) {
    return <ErrorDisplay message={error.message} />;
  }

  const handleLoadMore = () => {
    fetchMore({
      variables: {
        offset: data?.products.length || 0,
      },
    });
  };

  return (
    <div>
      <CategoryFilter selected={category} onChange={setCategory} />

      {loading && !data ? (
        <ProductGridSkeleton />
      ) : (
        <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
          {data?.products.map((product) => (
            <ProductCard key={product.id} product={product} />
          ))}
        </div>
      )}

      <button onClick={handleLoadMore} disabled={loading}>
        {loading ? 'Loading...' : 'Load More'}
      </button>
    </div>
  );
};

Mutations: Modifying Data

Optimistic Updates

Show the user instant feedback while the server processes the mutation:

// hooks/useAddToCart.ts
import { useAddToCartMutation, GetCartDocument } from '@/generated/graphql';

export const useAddToCart = () => {
  const [addToCart, { loading }] = useAddToCartMutation();

  const handleAddToCart = async (productId: string, quantity: number) => {
    await addToCart({
      variables: { productId, quantity },
      optimisticResponse: {
        addToCart: {
          __typename: 'CartItem',
          id: `temp-${Date.now()}`,
          product: {
            __typename: 'Product',
            id: productId,
            name: 'Loading...',
            price: 0,
          },
          quantity,
        },
      },
      update(cache, { data }) {
        if (!data?.addToCart) return;

        cache.modify({
          fields: {
            cart(existingCart = []) {
              const newCartItemRef = cache.writeFragment({
                data: data.addToCart,
                fragment: gql`
                  fragment NewCartItem on CartItem {
                    id
                    product {
                      id
                      name
                      price
                    }
                    quantity
                  }
                `,
              });
              return [...existingCart, newCartItemRef];
            },
          },
        });
      },
    });
  };

  return { handleAddToCart, loading };
};

Caching Strategies

Apollo Client's InMemoryCache is powerful but requires careful configuration for production use.

Pagination with Field Policies

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        // Cursor-based pagination
        feed: {
          keyArgs: false,
          merge(existing, incoming, { args }) {
            if (!args?.cursor) {
              return incoming; // Fresh query, replace cache
            }
            return {
              ...incoming,
              edges: [...(existing?.edges || []), ...incoming.edges],
            };
          },
        },

        // Offset-based pagination
        products: {
          keyArgs: ['category', 'sortBy'],
          merge(existing = [], incoming, { args }) {
            const merged = existing ? existing.slice(0) : [];
            const offset = args?.offset || 0;

            for (let i = 0; i < incoming.length; i++) {
              merged[offset + i] = incoming[i];
            }

            return merged;
          },
        },
      },
    },
  },
});

Cache Eviction and Garbage Collection

// After deleting a product
const [deleteProduct] = useDeleteProductMutation({
  update(cache, { data }) {
    if (!data?.deleteProduct) return;

    // Remove from cache
    cache.evict({
      id: cache.identify({
        __typename: 'Product',
        id: data.deleteProduct.id,
      }),
    });

    // Clean up dangling references
    cache.gc();
  },
});

Real-Time Data with Subscriptions

Setup WebSocket Link

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

const wsLink = new GraphQLWsLink(
  createClient({
    url: process.env.NEXT_PUBLIC_GRAPHQL_WS_URL!,
    connectionParams: () => ({
      authToken: getAuthToken(),
    }),
  })
);

const splitLink = split(
  ({ query }) => {
    const definition = getMainDefinition(query);
    return (
      definition.kind === 'OperationDefinition' &&
      definition.operation === 'subscription'
    );
  },
  wsLink,
  httpLink // from earlier setup
);

Using Subscriptions

// hooks/useOrderStatus.ts
import { useOrderStatusSubscription } from '@/generated/graphql';

export const useOrderStatus = (orderId: string) => {
  const { data, loading } = useOrderStatusSubscription({
    variables: { orderId },
    onData({ data: subscriptionData }) {
      if (subscriptionData.data?.orderStatusChanged.status === 'DELIVERED') {
        showNotification('Your order has been delivered!');
      }
    },
  });

  return {
    status: data?.orderStatusChanged.status,
    loading,
  };
};

Performance Optimization

1. Reduce Data Transfer with Fragments

fragment ProductCard on Product {
  id
  name
  price
  images {
    url
    alt
  }
  rating
}

query GetFeaturedProducts {
  featuredProducts {
    ...ProductCard
  }
}

query GetProductsByCategory($category: String!) {
  products(category: $category) {
    ...ProductCard
    description
    reviewCount
  }
}

2. Prefetch Queries on Hover

export const ProductLink: React.FC<{ productId: string }> = ({ productId }) => {
  const client = useApolloClient();

  const handleMouseEnter = () => {
    client.query({
      query: GetProductByIdDocument,
      variables: { id: productId },
    });
  };

  return (
    <Link
      href={`/products/${productId}`}
      onMouseEnter={handleMouseEnter}
    >
      View Product
    </Link>
  );
};

3. Batch Queries with @defer

query GetProductPage($id: ID!) {
  product(id: $id) {
    id
    name
    price
    description
    ... @defer {
      reviews {
        id
        author
        rating
        comment
      }
    }
  }
}

Error Handling Patterns

Component-Level Error Handling

export const ProductDetail: React.FC<{ id: string }> = ({ id }) => {
  const { data, loading, error } = useGetProductByIdQuery({
    variables: { id },
    errorPolicy: 'all', // Return partial data even with errors
  });

  if (error?.networkError) {
    return <NetworkErrorDisplay onRetry={() => window.location.reload()} />;
  }

  if (error?.graphQLErrors.some((e) => e.extensions?.code === 'NOT_FOUND')) {
    return <NotFoundDisplay />;
  }

  if (loading) return <ProductDetailSkeleton />;

  return <ProductDetailView product={data!.product} />;
};

Conclusion

GraphQL with Apollo Client transforms how you think about data in React applications. Instead of managing multiple REST endpoints and manually normalizing data, you declare what you need and the tooling handles the rest.

Key takeaways:

  • Use code generation for end-to-end type safety
  • Configure cache policies thoughtfully — they make or break performance
  • Implement optimistic updates for instant-feeling interactions
  • Use fragments to reduce duplication and over-fetching
  • Set up proper error handling at every layer
  • Prefetch data on hover for perceived performance gains

The initial setup investment pays dividends as your application scales — especially for complex data relationships where REST becomes unwieldy.


Found this blog helpful? Have questions or suggestions?

Related Blogs