JavaScript and React course Β· Module 10: React Ecosystem and Future

Apollo Client - Comprehensive GraphQL Integration with React

16 min read
In this lesson6

The command center asks a GraphQL server for users, their posts and comments. If every component called fetch on its own, the same data would arrive many times, and after a post is edited some screens would show the old version. Apollo Client sends GraphQL queries, keeps the responses in a normalized cache and refreshes components when the data changes. It also handles optimistic updates and subscriptions.

Installation and Basic Configuration

Apollo Client 4 needs three packages: @apollo/client, graphql and rxjs, because version 4 is built on RxJS observables:

1npm install @apollo/client graphql rxjs
2# Optional: WebSocket subscriptions and file uploads
3npm install graphql-ws apollo-upload-client

The examples in this lesson use the Apollo Client 4 API: you import the client, the cache and the links from @apollo/client, and ApolloProvider and hooks such as useQuery from @apollo/client/react. In older projects you will meet the version 3 API, where hooks were imported straight from @apollo/client and the client accepted a uri option.

The client is a chain of links that every query passes through, plus a cache:

1// apolloClient.js
2import { ApolloClient, ApolloLink, HttpLink, InMemoryCache, CombinedGraphQLErrors, ServerError } from '@apollo/client';
3import { SetContextLink } from '@apollo/client/link/context';
4import { ErrorLink } from '@apollo/client/link/error';
5
6// HTTP link for GraphQL server communication
7const httpLink = new HttpLink({
8  uri: process.env.REACT_APP_GRAPHQL_ENDPOINT || 'http://localhost:4000/graphql',
9  credentials: 'include' // For cookies/session
10});
11
12// Link for authorization handling
13const authLink = new SetContextLink(({ headers }) => {
14  const token = localStorage.getItem('authToken');
15
16  return {
17    headers: {
18      ...headers,
19      authorization: token ? `Bearer ${token}` : '',
20      'x-api-version': '1.0',
21      'x-client-name': 'react-app'
22    }
23  };
24});
25
26// Link for error handling
27const errorLink = new ErrorLink(({ error, operation, forward }) => {
28  if (CombinedGraphQLErrors.is(error)) {
29    error.errors.forEach(({ message, locations, path, extensions }) => {
30      console.error(
31        `GraphQL error: Message: ${message}, Location: ${JSON.stringify(locations)}, Path: ${path}`
32      );
33
34      // Handle specific errors
35      if (extensions?.code === 'UNAUTHENTICATED') {
36        localStorage.removeItem('authToken');
37        window.location.href = '/login';
38      }
39    });
40  } else if (ServerError.is(error)) {
41    console.error(`Server error: ${error.statusCode}`);
42
43    // Retry the request after a server error
44    if (error.statusCode === 500) {
45      return forward(operation);
46    }
47  } else {
48    console.error(`Network error: ${error}`);
49  }
50});
51
52// Cache configuration
53const cache = new InMemoryCache({
54  typePolicies: {
55    User: {
56      fields: {
57        // Cache configuration for user fields
58        posts: {
59          merge(existing = [], incoming) {
60            return [...existing, ...incoming];
61          }
62        }
63      }
64    },
65    Post: {
66      fields: {
67        comments: {
68          merge(existing = [], incoming) {
69            return incoming;
70          }
71        }
72      }
73    }
74  }
75});
76
77// Main Apollo client
78const apolloClient = new ApolloClient({
79  link: ApolloLink.from([errorLink, authLink, httpLink]),
80  cache,
81  defaultOptions: {
82    watchQuery: {
83      errorPolicy: 'all',
84      notifyOnNetworkStatusChange: true
85    },
86    query: {
87      errorPolicy: 'all'
88    }
89  },
90  devtools: { enabled: process.env.NODE_ENV === 'development' }
91});
92
93export default apolloClient;

ApolloLink.from([errorLink, authLink, httpLink]) sets the order: error handling, authorization header, HTTP dispatch. ErrorLink receives a single error object: CombinedGraphQLErrors.is(error) recognizes errors returned by GraphQL, and ServerError.is(error) an HTTP response with an error status. In SetContextLink the first argument is the previous context, hence ({ headers }). InMemoryCache normalizes the responses, meaning it stores each object separately under a key made of __typename and id, so a repeated query for the same data does not reach the server. typePolicies decide how lists are merged.

You make the client available to the whole application through ApolloProvider, much like context:

1// App.js
2import React from 'react';
3import { ApolloProvider } from '@apollo/client/react';
4import apolloClient from './apolloClient';
5import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
6import Dashboard from './components/Dashboard';
7import UserProfile from './components/UserProfile';
8
9function App() {
10  return (
11    <ApolloProvider client={apolloClient}>
12      <Router>
13        <div className="App">
14          <Routes>
15            <Route path="/" element={<Dashboard />} />
16            <Route path="/profile/:id" element={<UserProfile />} />
17          </Routes>
18        </div>
19      </Router>
20    </ApolloProvider>
21  );
22}
23
24export default App;

Every component inside the provider now uses the Apollo hooks and the shared cache.

Running Queries

useQuery runs a query automatically on render and returns data, loading and error. You describe the query with the gql template and pass the variables in the variables option:

1// GraphQL query definition
2import { gql } from '@apollo/client';
3import { useQuery } from '@apollo/client/react';
4
5const GET_USERS = gql`
6  query GetUsers($limit: Int, $offset: Int) {
7    users(limit: $limit, offset: $offset) {
8      id
9      name
10      email
11      avatar
12      posts {
13        id
14        title
15        createdAt
16      }
17    }
18  }
19`;
20
21function UsersList() {
22  const { data, loading, error, refetch, fetchMore } = useQuery(GET_USERS, {
23    variables: { limit: 10, offset: 0 },
24    notifyOnNetworkStatusChange: true,
25    errorPolicy: 'all'
26  });
27
28  if (loading) return <div className="loading">Loading users...</div>;
29  if (error) return <div className="error">Error: {error.message}</div>;
30
31  const loadMoreUsers = () => {
32    fetchMore({
33      variables: {
34        offset: data.users.length
35      },
36      updateQuery: (prev, { fetchMoreResult }) => {
37        if (!fetchMoreResult) return prev;
38
39        return {
40          ...prev,
41          users: [...prev.users, ...fetchMoreResult.users]
42        };
43      }
44    });
45  };
46
47  return (
48    <div>
49      <h2>Users List</h2>
50      <button onClick={() => refetch()}>Refresh</button>
51
52      <div className="users-grid">
53        {data.users.map(user => (
54          <div key={user.id} className="user-card">
55            <img src={user.avatar} alt={user.name} />
56            <h3>{user.name}</h3>
57            <p>{user.email}</p>
58            <p>Posts: {user.posts.length}</p>
59          </div>
60        ))}
61      </div>
62
63      <button onClick={loadMoreUsers}>Load More</button>
64    </div>
65  );
66}

The order matters: first you check loading, then error, and only at the end do you render data. fetchMore pulls in the next page, and updateQuery appends the new users to the previous ones.

On-Demand Queries

A search box should ask the server only after a click. useLazyQuery returns a function that runs the query on demand:

1import { gql } from '@apollo/client';
2import { useLazyQuery } from '@apollo/client/react';
3import { useState } from 'react';
4
5const SEARCH_USERS = gql`
6  query SearchUsers($query: String!) {
7    searchUsers(query: $query) {
8      id
9      name
10      email
11      avatar
12    }
13  }
14`;
15
16function UserSearch() {
17  const [searchTerm, setSearchTerm] = useState('');
18  const [searchUsers, { data, loading, error }] = useLazyQuery(SEARCH_USERS);
19
20  const handleSearch = (e) => {
21    e.preventDefault();
22    if (searchTerm.trim()) {
23      searchUsers({
24        variables: { query: searchTerm }
25      });
26    }
27  };
28
29  return (
30    <div>
31      <form onSubmit={handleSearch}>
32        <input
33          type="text"
34          value={searchTerm}
35          onChange={(e) => setSearchTerm(e.target.value)}
36          />
37        <button type="submit">Search</button>
38      </form>
39
40      {loading && <div>Searching...</div>}
41      {error && <div>Search error: {error.message}</div>}
42
43      {data?.searchUsers && (
44        <div>
45          <h3>Search Results:</h3>
46          {data.searchUsers.map(user => (
47            <div key={user.id}>{user.name} - {user.email}</div>
48          ))}
49        </div>
50      )}
51    </div>
52  );
53}

You call searchUsers in handleSearch, and the loading and error states work just like in useQuery.

Mutations

A mutation changes data on the server. Unlike useQuery, the useMutation hook sends nothing by itself, it only returns a function that you call on demand, e.g. in onSubmit:

1import { gql } from '@apollo/client';
2import { useMutation } from '@apollo/client/react';
3import { useState } from 'react';
4
5const CREATE_POST = gql`
6  mutation CreatePost($input: CreatePostInput!) {
7    createPost(input: $input) {
8      id
9      title
10      content
11      author {
12        id
13        name
14      }
15      createdAt
16    }
17  }
18`;
19
20const GET_POSTS = gql`
21  query GetPosts {
22    posts {
23      id
24      title
25      content
26      author {
27        id
28        name
29      }
30      createdAt
31    }
32  }
33`;
34
35function CreatePostForm() {
36  const [title, setTitle] = useState('');
37  const [content, setContent] = useState('');
38
39  const [createPost, { loading, error }] = useMutation(CREATE_POST, {
40    // Update cache after creating a post
41    update(cache, { data: { createPost } }) {
42      const existingPosts = cache.readQuery({ query: GET_POSTS });
43
44      cache.writeQuery({
45        query: GET_POSTS,
46        data: {
47          posts: [createPost, ...existingPosts.posts]
48        }
49      });
50    },
51
52    // Optimistic update
53    optimisticResponse: {
54      createPost: {
55        __typename: 'Post',
56        id: 'temp-id',
57        title,
58        content,
59        author: {
60          __typename: 'User',
61          id: 'current-user-id',
62          name: 'You'
63        },
64        createdAt: new Date().toISOString()
65      }
66    },
67
68    // Error handling
69    onError: (error) => {
70      console.error('Error creating post:', error);
71    },
72
73    onCompleted: (data) => {
74      console.log('Post created:', data.createPost);
75      setTitle('');
76      setContent('');
77    }
78  });
79
80  const handleSubmit = async (e) => {
81    e.preventDefault();
82
83    if (!title.trim() || !content.trim()) {
84      alert('Please fill in all fields');
85      return;
86    }
87
88    try {
89      await createPost({
90        variables: {
91          input: { title, content }
92        }
93      });
94    } catch (err) {
95      console.error('Error creating post:', err);
96    }
97  };
98
99  return (
100    <form onSubmit={handleSubmit}>
101      <div>
102        <label>Title:</label>
103        <input
104          type="text"
105          value={title}
106          onChange={(e) => setTitle(e.target.value)}
107          disabled={loading}
108        />
109      </div>
110
111      <div>
112        <label>Content:</label>
113        <textarea
114          value={content}
115          onChange={(e) => setContent(e.target.value)}
116          disabled={loading}
117        />
118      </div>
119
120      <button type="submit" disabled={loading}>
121        {loading ? 'Creating...' : 'Create Post'}
122      </button>
123
124      {error && <div className="error">Error: {error.message}</div>}
125    </form>
126  );
127}

update writes the new post into the GET_POSTS query in the cache, so the list refreshes without asking the server. optimisticResponse shows the post immediately, and if the mutation fails, Apollo rolls back this temporary version.

Advanced Cache Management

When a post is deleted, the cache has to be cleaned up by hand, because Apollo does not know which lists contained that object:

1import { gql } from '@apollo/client';
2import { useMutation, useQuery } from '@apollo/client/react';
3
4const DELETE_POST = gql`
5  mutation DeletePost($id: ID!) {
6    deletePost(id: $id) {
7      id
8    }
9  }
10`;
11
12const UPDATE_POST = gql`
13  mutation UpdatePost($id: ID!, $input: UpdatePostInput!) {
14    updatePost(id: $id, input: $input) {
15      id
16      title
17      content
18      updatedAt
19    }
20  }
21`;
22
23function PostManager({ postId }) {
24  const [deletePost] = useMutation(DELETE_POST, {
25    // Remove from cache after deletion
26    update(cache, { data: { deletePost } }) {
27      cache.modify({
28        fields: {
29          posts(existingPosts = [], { readField }) {
30            return existingPosts.filter(
31              postRef => deletePost.id !== readField('id', postRef)
32            );
33          }
34        }
35      });
36
37      // Also remove the individual post from cache
38      cache.evict({ id: cache.identify({ __typename: 'Post', id: deletePost.id }) });
39      cache.gc();
40    }
41  });
42
43  const [updatePost] = useMutation(UPDATE_POST, {
44    // No update needed - Apollo automatically updates cache
45    // if returned data has the same ID and __typename
46  });
47
48  const handleDelete = async () => {
49    if (window.confirm('Are you sure you want to delete this post?')) {
50      try {
51        await deletePost({
52          variables: { id: postId }
53        });
54      } catch (error) {
55        console.error('Error deleting post:', error);
56      }
57    }
58  };
59
60  const handleUpdate = async (newTitle, newContent) => {
61    try {
62      await updatePost({
63        variables: {
64          id: postId,
65          input: { title: newTitle, content: newContent }
66        }
67      });
68    } catch (error) {
69      console.error('Error updating post:', error);
70    }
71  };
72
73  return (
74    <div>
75      <button onClick={handleDelete}>Delete Post</button>
76      {/* Edit form */}
77    </div>
78  );
79}

cache.modify cuts the reference out of the posts list, and evict and gc remove the object itself. For an update you do not have to do anything: a response with the same id and __typename overwrites the object in the cache by itself.

Subscriptions

Subscriptions deliver data in real time over WebSocket. ApolloLink.split routes them to GraphQLWsLink, and queries and mutations to HTTP:

1// apolloClient.js - extended configuration
2import { OperationTypeNode } from 'graphql';
3import { ApolloLink } from '@apollo/client';
4import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
5import { createClient } from 'graphql-ws';
6
7// WebSocket link for subscriptions
8const wsLink = new GraphQLWsLink(
9  createClient({
10    url: process.env.REACT_APP_GRAPHQL_WS_ENDPOINT || 'ws://localhost:4000/graphql',
11    connectionParams: () => {
12      const token = localStorage.getItem('authToken');
13      return {
14        authorization: token ? `Bearer ${token}` : ''
15      };
16    },
17    on: {
18      connected: () => console.log('WebSocket connected'),
19      closed: () => console.log('WebSocket disconnected')
20    }
21  })
22);
23
24// Traffic splitting - HTTP for queries/mutations, WebSocket for subscriptions
25const splitLink = ApolloLink.split(
26  ({ operationType }) => operationType === OperationTypeNode.SUBSCRIPTION,
27  wsLink,
28  ApolloLink.from([errorLink, authLink, httpLink])
29);
30
31// Updated client
32const apolloClient = new ApolloClient({
33  link: splitLink, // Use splitLink instead of ApolloLink.from([...])
34  cache,
35  // ... rest of configuration
36});

In version 4 the operation has an operationType field, so the test compares it with OperationTypeNode.SUBSCRIPTION from the graphql package. The graphql-ws package is the successor of the unmaintained subscriptions-transport-ws, so choose it.

A component listens for events with the useSubscription hook and adds new posts to the cache:

1import { gql } from '@apollo/client';
2import { useSubscription, useQuery } from '@apollo/client/react';
3
4const POST_ADDED_SUBSCRIPTION = gql`
5  subscription PostAdded {
6    postAdded {
7      id
8      title
9      content
10      author {
11        id
12        name
13      }
14      createdAt
15    }
16  }
17`;
18
19const COMMENT_ADDED_SUBSCRIPTION = gql`
20  subscription CommentAdded($postId: ID!) {
21    commentAdded(postId: $postId) {
22      id
23      content
24      author {
25        id
26        name
27      }
28      createdAt
29    }
30  }
31`;
32
33function LivePostsFeed() {
34  const { data: postsData, loading } = useQuery(GET_POSTS);
35
36  // Subscribe to new posts
37  useSubscription(POST_ADDED_SUBSCRIPTION, {
38    onData: ({ data, client }) => {
39      const newPost = data.data.postAdded;
40
41      // Update cache
42      const existingPosts = client.readQuery({ query: GET_POSTS });
43      client.writeQuery({
44        query: GET_POSTS,
45        data: {
46          posts: [newPost, ...existingPosts.posts]
47        }
48      });
49
50      // Show notification
51      showNotification(`New post: ${newPost.title}`);
52    }
53  });
54
55  if (loading) return <div>Loading...</div>;
56
57  return (
58    <div>
59      <h2>Live Posts</h2>
60      {postsData?.posts.map(post => (
61        <LivePostCard key={post.id} post={post} />
62      ))}
63    </div>
64  );
65}
66
67function LivePostCard({ post }) {
68  // Subscribe to new comments for this post
69  useSubscription(COMMENT_ADDED_SUBSCRIPTION, {
70    variables: { postId: post.id },
71    onData: ({ data }) => {
72      const newComment = data.data.commentAdded;
73      console.log(`New comment on post ${post.title}:`, newComment.content);
74    }
75  });
76
77  return (
78    <div className="post-card">
79      <h3>{post.title}</h3>
80      <p>{post.content}</p>
81      <small>Author: {post.author.name}</small>
82    </div>
83  );
84}

onData receives the client and the subscription result, so the new post is in data.data.postAdded. Apollo Client 4 removed the older onSubscriptionData name, so such a callback no longer fires.

Advanced Patterns

A fragment is a reusable set of fields. You define USER_FRAGMENT once and paste it into every query about a user:

1// fragments.js
2import { gql } from '@apollo/client';
3
4export const USER_FRAGMENT = gql`
5  fragment UserInfo on User {
6    id
7    name
8    email
9    avatar
10  }
11`;
12
13export const POST_FRAGMENT = gql`
14  fragment PostInfo on Post {
15    id
16    title
17    content
18    createdAt
19    author {
20      ...UserInfo
21    }
22  }
23  ${USER_FRAGMENT}
24`;
25
26// Using fragments
27const GET_POSTS_WITH_FRAGMENTS = gql`
28  query GetPosts {
29    posts {
30      ...PostInfo
31    }
32  }
33  ${POST_FRAGMENT}
34`;

Changing the fields in the fragment updates every query that uses it, and ${USER_FRAGMENT} attaches its definition to the document.

usePostOperations wraps a query, a mutation and pagination in one hook, so the page component sees a simple API:

1// hooks/usePostOperations.js
2import { gql } from '@apollo/client';
3import { useQuery, useMutation } from '@apollo/client/react';
4import { POST_FRAGMENT } from '../fragments';
5
6const GET_POSTS = gql`
7  query GetPosts($limit: Int, $offset: Int) {
8    posts(limit: $limit, offset: $offset) {
9      ...PostInfo
10    }
11  }
12  ${POST_FRAGMENT}
13`;
14
15const CREATE_POST = gql`
16  mutation CreatePost($input: CreatePostInput!) {
17    createPost(input: $input) {
18      ...PostInfo
19    }
20  }
21  ${POST_FRAGMENT}
22`;
23
24export function usePostOperations() {
25  const { data, loading, error, refetch, fetchMore } = useQuery(GET_POSTS, {
26    variables: { limit: 10, offset: 0 }
27  });
28
29  const [createPostMutation, { loading: creating }] = useMutation(CREATE_POST, {
30    update(cache, { data: { createPost } }) {
31      const existingPosts = cache.readQuery({ query: GET_POSTS });
32      cache.writeQuery({
33        query: GET_POSTS,
34        data: {
35          posts: [createPost, ...existingPosts.posts]
36        }
37      });
38    }
39  });
40
41  const createPost = async (postData) => {
42    try {
43      const result = await createPostMutation({
44        variables: { input: postData }
45      });
46      return { success: true, post: result.data.createPost };
47    } catch (error) {
48      return { success: false, error: error.message };
49    }
50  };
51
52  const loadMore = () => {
53    return fetchMore({
54      variables: { offset: data?.posts?.length || 0 },
55      updateQuery: (prev, { fetchMoreResult }) => ({
56        ...prev,
57        posts: [...prev.posts, ...fetchMoreResult.posts]
58      })
59    });
60  };
61
62  return {
63    posts: data?.posts || [],
64    loading,
65    error,
66    creating,
67    createPost,
68    refetch,
69    loadMore
70  };
71}
72
73// Using the custom hook
74function PostsPage() {
75  const { posts, loading, createPost, loadMore } = usePostOperations();
76
77  const handleCreatePost = async (formData) => {
78    const result = await createPost(formData);
79    if (result.success) {
80      console.log('Post created successfully');
81    } else {
82      console.error('Failed to create post:', result.error);
83    }
84  };
85
86  return (
87    <div>
88      <CreatePostForm onSubmit={handleCreatePost} />
89      {loading ? (
90        <div>Loading...</div>
91      ) : (
92        <PostsList posts={posts} onLoadMore={loadMore} />
93      )}
94    </div>
95  );
96}

PostsPage knows nothing about GraphQL. When you change the schema, you fix one hook instead of ten components, and I recommend this split.

GraphQLErrorBoundary catches a rendering error and shows a fallback screen with a retry button, instead of bringing down the whole tree:

1import React from 'react';
2import { CombinedGraphQLErrors, ServerError } from '@apollo/client';
3import { ApolloProvider } from '@apollo/client/react';
4
5class GraphQLErrorBoundary extends React.Component {
6  constructor(props) {
7    super(props);
8    this.state = { hasError: false, error: null };
9  }
10
11  static getDerivedStateFromError(error) {
12    return { hasError: true, error };
13  }
14
15  componentDidCatch(error, errorInfo) {
16    if (CombinedGraphQLErrors.is(error)) {
17      console.error('GraphQL Error:', error.errors);
18    } else if (ServerError.is(error)) {
19      console.error('Server Error:', error.statusCode);
20    }
21
22    // Send error to monitoring system
23    this.logErrorToService(error, errorInfo);
24  }
25
26  logErrorToService(error, errorInfo) {
27    // Integration with Sentry, LogRocket, etc.
28    console.error('Error logged:', error, errorInfo);
29  }
30
31  render() {
32    if (this.state.hasError) {
33      return (
34        <div className="error-boundary">
35          <h2>Something went wrong with GraphQL</h2>
36          <details>
37            <summary>Error details</summary>
38            <pre>{this.state.error?.message}</pre>
39          </details>
40          <button onClick={() => this.setState({ hasError: false, error: null })}>
41            Try again
42          </button>
43        </div>
44      );
45    }
46
47    return this.props.children;
48  }
49}
50
51// Usage
52function App() {
53  return (
54    <ApolloProvider client={apolloClient}>
55      <GraphQLErrorBoundary>
56        <Router>
57          {/* Your application */}
58        </Router>
59      </GraphQLErrorBoundary>
60    </ApolloProvider>
61  );
62}

Apollo Client 4 no longer has the ApolloError class from version 3: you recognize GraphQL errors with CombinedGraphQLErrors.is(error) (the list is in error.errors), and an HTTP response with an error status with ServerError.is(error). They reach an error boundary from hooks such as useSuspenseQuery, because useQuery does not throw the error, it returns it in the error field.

For tests there is MockedProvider, which returns prepared responses instead of a server:

1// __tests__/UsersList.test.js
2import React from 'react';
3import { render, screen, waitFor } from '@testing-library/react';
4import { MockedProvider } from '@apollo/client/testing/react';
5import UsersList, { GET_USERS } from '../UsersList';
6
7const mocks = [
8  {
9    request: {
10      query: GET_USERS,
11      variables: { limit: 10, offset: 0 }
12    },
13    result: {
14      data: {
15        users: [
16          {
17            __typename: 'User',
18            id: '1',
19            name: 'John Doe',
20            email: 'john@example.com',
21            avatar: 'https://example.com/avatar1.jpg',
22            posts: []
23          }
24        ]
25      }
26    }
27  }
28];
29
30test('renders users list', async () => {
31  render(
32    <MockedProvider mocks={mocks}>
33      <UsersList />
34    </MockedProvider>
35  );
36
37  // Check loading state
38  expect(screen.getByText('Loading users...')).toBeInTheDocument();
39
40  // Wait for data to load
41  await waitFor(() => {
42    expect(screen.getByText('John Doe')).toBeInTheDocument();
43  });
44});
45
46test('handles error state', async () => {
47  const errorMocks = [
48    {
49      request: {
50        query: GET_USERS,
51        variables: { limit: 10, offset: 0 }
52      },
53      error: new Error('Network error')
54    }
55  ];
56
57  render(
58    <MockedProvider mocks={errorMocks}>
59      <UsersList />
60    </MockedProvider>
61  );
62
63  await waitFor(() => {
64    expect(screen.getByText(/Error: Network error/)).toBeInTheDocument();
65  });
66});

The mock must match the query and variables exactly, otherwise the test gets an error. In Apollo Client 4 you import MockedProvider from @apollo/client/testing/react, and the addTypename option is gone: the cache always adds __typename to queries, so the mock data includes it too and normalization works like in production. The second test checks whether the error message reaches the screen.

When to Use Apollo Client?

Apollo Client shines when the backend uses GraphQL and you need a cache that normalizes and deduplicates data by itself. It helps with optimistic updates, for example a post like is visible immediately, with real-time data through subscriptions, and Apollo DevTools show the cache, queries and mutations in the browser. The alternatives are the lighter urql with a simpler API, React Query with the simple graphql-request client, and Relay from Meta, optimized for large applications. You will get to know React Query, meaning TanStack Query, in one of the next lessons.

Remember: Apollo Client is the ship's communications system, which not only receives signals, but also buffers them in the cache and distributes them to every module that needs them.

Code for this lesson: App.jsx
1import React, { useState, useEffect } from 'react';
2
3// Apollo Client simulation: queries, a mutation and a normalized cache
4// In a real app this is done by ApolloClient, InMemoryCache, useQuery and useMutation
5
6// Data on the GraphQL "server" side
7let serverMissions = [
8  { id: '1', name: 'Nebula Explorer', status: 'ACTIVE', crew: 12, progress: 78 },
9  { id: '2', name: 'Dark Matter Probe', status: 'PLANNING', crew: 5, progress: 15 },
10  { id: '3', name: 'Solar Flare Watch', status: 'COMPLETED', crew: 8, progress: 100 },
11  { id: '4', name: 'Asteroid Mining Delta', status: 'ACTIVE', crew: 20, progress: 45 },
12];
13const serverCrew = [
14  { id: '1', name: 'Commander Nova', role: 'command', missionId: '1' },
15  { id: '2', name: 'Dr. Stellar', role: 'science', missionId: '1' },
16  { id: '3', name: 'Astro-7', role: 'engineering', missionId: '2' },
17  { id: '4', name: 'Ra', role: 'navigation', missionId: '4' },
18];
19
20const STATUS_LABELS = { ACTIVE: 'Active', PLANNING: 'Planning', COMPLETED: 'Completed', SUSPENDED: 'Suspended' };
21const STATUS_COLORS = { ACTIVE: '#00ff88', PLANNING: '#ffa500', COMPLETED: '#64ffda', SUSPENDED: '#ff6b6b' };
22
23// Normalized cache like InMemoryCache: every object is stored once under a Type:id key,
24// and a query result keeps only a list of keys
25const cache = { objects: new Map(), queries: new Map() };
26
27function writeQuery(queryName, missions) {
28  missions.forEach((mission) => cache.objects.set(`Mission:${mission.id}`, mission));
29  cache.queries.set(queryName, missions.map((mission) => `Mission:${mission.id}`));
30}
31
32function readQuery(queryName) {
33  const refs = cache.queries.get(queryName);
34  return refs ? refs.map((ref) => cache.objects.get(ref)) : null;
35}
36
37const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
38
39// Query: the cache first, and the "network" only when data is missing
40async function runQuery(queryName, variables = {}) {
41  if (queryName === 'GET_MISSIONS') {
42    const cached = readQuery('GET_MISSIONS');
43    if (cached) return { data: cached, fromCache: true };
44    await delay(800);
45    writeQuery('GET_MISSIONS', serverMissions);
46    return { data: readQuery('GET_MISSIONS'), fromCache: false };
47  }
48  if (queryName === 'GET_MISSION_CREW') {
49    await delay(500);
50    return { data: serverCrew.filter((member) => member.missionId === variables.id), fromCache: false };
51  }
52  throw new Error(`Unknown query: ${queryName}`);
53}
54
55// The mutation changes data on the server and returns an object with an id, so the cache
56// replaces the Mission:id object, and every list with that key sees the new version
57async function runMutation(mutationName, variables) {
58  await delay(500);
59  if (mutationName !== 'UPDATE_MISSION_STATUS') throw new Error(`Unknown mutation: ${mutationName}`);
60  const mission = serverMissions.find((item) => item.id === variables.id);
61  if (!mission) throw new Error(`Mission not found: ${variables.id}`);
62  const updated = { ...mission, status: variables.status };
63  serverMissions = serverMissions.map((item) => (item.id === updated.id ? updated : item));
64  cache.objects.set(`Mission:${updated.id}`, updated);
65  return { data: { updateMissionStatus: updated } };
66}
67
68// Hook simulating useQuery: returns loading, data and error
69function useSimulatedQuery(queryName, variables = {}, refreshKey = 0) {
70  const [state, setState] = useState({ loading: true, data: null, error: null, fromCache: false });
71  const variablesKey = JSON.stringify(variables);
72
73  useEffect(() => {
74    let ignore = false;
75    setState((prev) => ({ ...prev, loading: true }));
76    runQuery(queryName, JSON.parse(variablesKey))
77      .then((result) => {
78        if (!ignore) setState({ loading: false, data: result.data, error: null, fromCache: result.fromCache });
79      })
80      .catch((error) => {
81        if (!ignore) setState({ loading: false, data: null, error: error.message, fromCache: false });
82      });
83    return () => {
84      ignore = true;
85    };
86  }, [queryName, variablesKey, refreshKey]);
87
88  return state;
89}
90
91function MissionList({ selectedId, onSelect, refreshKey }) {
92  const { loading, data, error, fromCache } = useSimulatedQuery('GET_MISSIONS', {}, refreshKey);
93
94  if (loading && !data) return <div style={styles.loader}>Loading data from the GraphQL server...</div>;
95  if (error) return <div style={styles.error}>Error: {error}</div>;
96
97  return (
98    <div style={styles.panel}>
99      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px' }}>
100        <h3 style={styles.heading}>Space Missions</h3>
101        <span style={fromCache ? styles.cacheBadge : styles.serverBadge}>{fromCache ? 'From cache' : 'From server'}</span>
102      </div>
103      {data.map((mission) => (
104        <button
105          key={mission.id}
106          onClick={() => onSelect(mission.id)}
107          style={{ ...styles.missionCard, borderColor: mission.id === selectedId ? '#00d4ff' : 'transparent' }}
108        >
109          <div style={{ display: 'flex', justifyContent: 'space-between', gap: '8px' }}>
110            <strong style={{ color: '#e0e1dd' }}>{mission.name}</strong>
111            <span style={{ color: STATUS_COLORS[mission.status], fontSize: '12px' }}>{STATUS_LABELS[mission.status]}</span>
112          </div>
113          <div style={styles.progressBar}>
114            <div style={{ ...styles.progressFill, width: `${mission.progress}%` }} />
115          </div>
116          <span style={{ color: '#8892b0', fontSize: '12px' }}>Crew: {mission.crew} | Progress: {mission.progress}%</span>
117        </button>
118      ))}
119    </div>
120  );
121}
122
123function MissionCrew({ missionId }) {
124  const { loading, data } = useSimulatedQuery('GET_MISSION_CREW', { id: missionId });
125
126  if (loading) return <p style={styles.muted}>Loading crew...</p>;
127  if (data.length === 0) return <p style={styles.muted}>No crew assigned.</p>;
128  return (
129    <ul style={{ margin: '0 0 12px', paddingLeft: '18px', color: '#e0e1dd', fontSize: '13px' }}>
130      {data.map((member) => (
131        <li key={member.id}>{member.name} - {member.role}</li>
132      ))}
133    </ul>
134  );
135}
136
137function MutationPanel({ selectedId, onMutated }) {
138  const [mutating, setMutating] = useState(false);
139  const [result, setResult] = useState(null);
140  const [error, setError] = useState(null);
141
142  if (!selectedId) {
143    return (
144      <div style={styles.panel}>
145        <h3 style={styles.heading}>GraphQL Mutations</h3>
146        <p style={styles.muted}>Select a mission from the list to see its crew and change its status.</p>
147      </div>
148    );
149  }
150
151  const handleMutation = async (status) => {
152    setMutating(true);
153    setError(null);
154    try {
155      const { data } = await runMutation('UPDATE_MISSION_STATUS', { id: selectedId, status });
156      const mission = data.updateMissionStatus;
157      setResult(`${mission.name}: ${STATUS_LABELS[mission.status]}. The server returned an object with an id, so the cache replaced Mission:${mission.id}, and the list read it without a new query.`);
158      onMutated();
159    } catch (err) {
160      setError(err.message);
161    } finally {
162      setMutating(false);
163    }
164  };
165
166  return (
167    <div style={styles.panel}>
168      <h3 style={styles.heading}>GraphQL Mutations</h3>
169      <p style={{ ...styles.muted, marginTop: 0 }}>Crew (query with an id variable):</p>
170      <MissionCrew missionId={selectedId} />
171      <p style={{ ...styles.muted, marginTop: 0 }}>Change status (mutation):</p>
172      <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
173        {Object.keys(STATUS_LABELS).map((status) => (
174          <button key={status} onClick={() => handleMutation(status)} disabled={mutating}
175            style={{ ...styles.btn, opacity: mutating ? 0.5 : 1 }}>
176            {STATUS_LABELS[status]}
177          </button>
178        ))}
179      </div>
180      {mutating && <p style={styles.muted}>Sending the mutation...</p>}
181      {result && !mutating && <p style={{ color: '#00ff88', fontSize: '13px', marginTop: '10px' }}>{result}</p>}
182      {error && <p style={styles.error}>{error}</p>}
183    </div>
184  );
185}
186
187function App() {
188  const [selectedMission, setSelectedMission] = useState(null);
189  const [refreshKey, setRefreshKey] = useState(0);
190
191  return (
192    <div style={styles.container}>
193      <h1 style={styles.title}>Apollo Client - GraphQL Mission Control</h1>
194      <p style={styles.subtitle}>Simulation of queries, mutations and a normalized cache</p>
195      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: '16px' }}>
196        <MissionList selectedId={selectedMission} onSelect={setSelectedMission} refreshKey={refreshKey} />
197        <MutationPanel key={selectedMission} selectedId={selectedMission} onMutated={() => setRefreshKey((key) => key + 1)} />
198      </div>
199      <p style={styles.note}>After a mutation the list shows the "From cache" badge: the new status came in the mutation response and went into the cache, so the GET_MISSIONS query did not have to ask the server. In Apollo Client, useQuery would refresh the list by itself, because it watches the cache.</p>
200    </div>
201  );
202}
203
204const styles = {
205  container: { minHeight: '100vh', background: 'linear-gradient(135deg, #0d1b2a 0%, #1a1a3e 100%)', padding: '24px 16px', color: '#e0e1dd', fontFamily: 'system-ui, sans-serif', boxSizing: 'border-box' },
206  title: { textAlign: 'center', color: '#00d4ff', margin: '0 0 4px', fontSize: '24px' },
207  subtitle: { textAlign: 'center', color: '#8892b0', marginBottom: '20px' },
208  panel: { background: 'rgba(0,0,0,0.3)', borderRadius: '12px', padding: '16px', border: '1px solid rgba(0,212,255,0.2)' },
209  heading: { color: '#00d4ff', margin: '0 0 12px', fontSize: '17px' },
210  missionCard: { display: 'block', width: '100%', textAlign: 'left', padding: '12px', background: 'rgba(0,0,0,0.3)', borderRadius: '8px', marginBottom: '8px', cursor: 'pointer', border: '1px solid transparent', color: 'inherit', font: 'inherit' },
211  progressBar: { height: '6px', background: 'rgba(255,255,255,0.1)', borderRadius: '3px', margin: '8px 0' },
212  progressFill: { height: '100%', background: 'linear-gradient(90deg, #00d4ff, #00ff88)', borderRadius: '3px', transition: 'width 0.3s' },
213  cacheBadge: { background: '#00ff88', color: '#0d1b2a', padding: '2px 8px', borderRadius: '4px', fontSize: '11px', fontWeight: 'bold', whiteSpace: 'nowrap' },
214  serverBadge: { background: 'rgba(0,212,255,0.2)', color: '#00d4ff', padding: '2px 8px', borderRadius: '4px', fontSize: '11px', fontWeight: 'bold', whiteSpace: 'nowrap' },
215  loader: { color: '#8892b0', textAlign: 'center', padding: '40px' },
216  error: { color: '#ff6b6b', fontSize: '13px' },
217  muted: { color: '#8892b0', fontSize: '13px' },
218  btn: { padding: '8px 12px', background: 'rgba(0,212,255,0.15)', border: '1px solid #00d4ff', borderRadius: '6px', color: '#00d4ff', cursor: 'pointer', fontSize: '12px' },
219  note: { maxWidth: '760px', margin: '16px auto 0', color: '#8892b0', fontSize: '13px', lineHeight: 1.5 },
220};
221
222export default App;

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What role does InMemoryCache play in Apollo Client?

  2. 2. How does the useMutation hook differ from useQuery in Apollo Client?

Hands-on tasks in the game

  • Click in order

    Click the elements in the order from the lesson: the useQuery call, handling loading, handling the error and displaying the data:

  • Click in order

    Arrange the syntax for using the useQuery hook with Apollo Client:

  • Horizontal ordering

    Arrange the elements of a useQuery call in Apollo Client from left to right:

Useful articles