TechLead
Lesson 9 of 20
5 min read
GraphQL

Apollo Client with React

Integrate Apollo Client into React applications for data fetching, caching, optimistic updates, and state management

Setting Up Apollo Client

Apollo Client is a comprehensive state management library for JavaScript that enables you to manage both local and remote data with GraphQL. It provides a powerful caching layer, declarative data fetching with React hooks, and features like optimistic UI updates and pagination.

# Install Apollo Client and GraphQL
npm install @apollo/client graphql

Client Configuration

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

// HTTP connection to the GraphQL API
const httpLink = createHttpLink({
  uri: process.env.NEXT_PUBLIC_GRAPHQL_URL || 'http://localhost:4000/graphql',
  credentials: 'include',
});

// Authentication link — adds token to every request
const authLink = setContext((_, { headers }) => {
  const token = typeof window !== 'undefined'
    ? localStorage.getItem('auth-token')
    : null;

  return {
    headers: {
      ...headers,
      authorization: token ? `Bearer ${token}` : '',
    },
  };
});

// Error handling link
const errorLink = onError(({ graphQLErrors, networkError }) => {
  if (graphQLErrors) {
    graphQLErrors.forEach(({ message, locations, path, extensions }) => {
      console.error(`[GraphQL error]: ${message}`);

      if (extensions?.code === 'UNAUTHENTICATED') {
        // Redirect to login or refresh token
        localStorage.removeItem('auth-token');
        window.location.href = '/login';
      }
    });
  }
  if (networkError) {
    console.error(`[Network error]: ${networkError}`);
  }
});

// Cache configuration
const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        posts: {
          // Merge paginated results
          keyArgs: ['filter'],
          merge(existing = [], incoming, { args }) {
            const offset = args?.offset || 0;
            const merged = existing.slice(0);
            for (let i = 0; i < incoming.length; i++) {
              merged[offset + i] = incoming[i];
            }
            return merged;
          },
        },
      },
    },
    User: {
      fields: {
        posts: {
          merge: false, // Always replace, don't merge
        },
      },
    },
  },
});

export const apolloClient = new ApolloClient({
  link: from([errorLink, authLink, httpLink]),
  cache,
  defaultOptions: {
    watchQuery: {
      fetchPolicy: 'cache-and-network',
    },
  },
});

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>
  );
}

// app/layout.tsx
import { Providers } from './providers';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

useQuery Hook

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

const GET_POSTS = gql`
  query GetPosts($limit: Int!, $offset: Int!) {
    posts(filter: { isPublished: true }, limit: $limit, offset: $offset) {
      id
      title
      excerpt
      author { id name avatarUrl }
      publishedAt
      likesCount
    }
  }
`;

function PostList() {
  const { data, loading, error, fetchMore, refetch } = useQuery(GET_POSTS, {
    variables: { limit: 10, offset: 0 },
    notifyOnNetworkStatusChange: true,
  });

  if (loading && !data) return <PostListSkeleton />;
  if (error) return <ErrorMessage error={error} retry={() => refetch()} />;

  return (
    <div className="space-y-4">
      {data.posts.map((post: any) => (
        <PostCard key={post.id} post={post} />
      ))}
      <button
        onClick={() => fetchMore({ variables: { offset: data.posts.length } })}
        disabled={loading}
        className="px-4 py-2 bg-blue-600 text-white rounded-lg"
      >
        {loading ? 'Loading...' : 'Load More'}
      </button>
    </div>
  );
}

useMutation Hook

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

const CREATE_POST = gql`
  mutation CreatePost($input: CreatePostInput!) {
    createPost(input: $input) {
      id
      title
      body
      slug
      isPublished
      createdAt
    }
  }
`;

function CreatePostForm() {
  const [createPost, { loading, error }] = useMutation(CREATE_POST, {
    // Update the cache after mutation
    update(cache, { data: { createPost: newPost } }) {
      cache.modify({
        fields: {
          posts(existingPosts = []) {
            const newPostRef = cache.writeFragment({
              data: newPost,
              fragment: gql`
                fragment NewPost on Post {
                  id
                  title
                  slug
                  isPublished
                  createdAt
                }
              `,
            });
            return [newPostRef, ...existingPosts];
          },
        },
      });
    },
    onCompleted: (data) => {
      router.push(`/posts/${data.createPost.slug}`);
    },
    onError: (error) => {
      toast.error(error.message);
    },
  });

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);

    await createPost({
      variables: {
        input: {
          title: formData.get('title'),
          body: formData.get('body'),
          isPublished: true,
        },
      },
    });
  };

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <input name="title" placeholder="Post title" required className="w-full p-3 border rounded" />
      <textarea name="body" placeholder="Write your post..." required className="w-full p-3 border rounded h-40" />
      {error && <p className="text-red-500">{error.message}</p>}
      <button type="submit" disabled={loading} className="px-6 py-2 bg-blue-600 text-white rounded">
        {loading ? 'Publishing...' : 'Publish Post'}
      </button>
    </form>
  );
}

Optimistic Updates

Optimistic updates make the UI feel instant by updating the cache before the server responds. If the mutation fails, Apollo automatically rolls back the optimistic response:

const LIKE_POST = gql`
  mutation LikePost($postId: ID!) {
    likePost(id: $postId) {
      id
      likesCount
      isLikedByViewer
    }
  }
`;

function LikeButton({ post }: { post: { id: string; likesCount: number; isLikedByViewer: boolean } }) {
  const [likePost] = useMutation(LIKE_POST, {
    optimisticResponse: {
      likePost: {
        __typename: 'Post',
        id: post.id,
        likesCount: post.isLikedByViewer ? post.likesCount - 1 : post.likesCount + 1,
        isLikedByViewer: !post.isLikedByViewer,
      },
    },
  });

  return (
    <button
      onClick={() => likePost({ variables: { postId: post.id } })}
      className={`flex items-center gap-2 px-3 py-1 rounded-full ${
        post.isLikedByViewer ? 'bg-red-100 text-red-600' : 'bg-gray-100 text-gray-600'
      }`}
    >
      {post.isLikedByViewer ? '❤️' : '🤍'} {post.likesCount}
    </button>
  );
}

Apollo Client Best Practices

  • Use cache-and-network: Show cached data immediately, then update when network responds
  • Optimistic updates for mutations: Make the UI feel instant by predicting the server response
  • Error boundaries: Wrap query components in React error boundaries for graceful failure
  • Co-locate queries: Define queries near the components that use them, not in a centralized folder
  • Use TypedDocumentNode: Enable full type safety between queries and components

Continue Learning