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.
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?
| Feature | REST | GraphQL |
|---|---|---|
| Data fetching | Multiple endpoints | Single endpoint |
| Over-fetching | Common | Eliminated |
| Under-fetching | Requires multiple calls | Solved with nested queries |
| Typing | External schemas | Built-in type system |
| Real-time | WebSockets (separate) | Subscriptions (native) |
| Caching | Manual, complex | Automatic 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.