Kurs JavaScript i React · Moduł 10: Ekosystem i przyszłość React

Apollo Client - kompleksowa integracja GraphQL z React

15 min czytania
W tej lekcji6

Centrum dowodzenia pyta serwer GraphQL o użytkowników, ich posty i komentarze. Gdyby każdy komponent sam wołał fetch, te same dane przylatywałyby wielokrotnie, a po edycji posta część ekranów pokazywałaby starą wersję. Apollo Client wysyła zapytania GraphQL, trzyma odpowiedzi w znormalizowanym cache i odświeża komponenty, gdy dane się zmienią. Obsługuje też optymistyczne aktualizacje i subskrypcje.

Instalacja i podstawowa konfiguracja

Apollo Client 4 potrzebuje trzech pakietów: @apollo/client, graphql i rxjs, bo wersja 4 opiera się na obserwablach z RxJS:

1npm install @apollo/client graphql rxjs
2# Opcjonalnie: subskrypcje przez WebSocket i wysyłanie plików
3npm install graphql-ws apollo-upload-client

Przykłady w tej lekcji używają API Apollo Client 4: klienta, cache i linki importujesz z @apollo/client, a ApolloProvider i hooki, jak useQuery, z @apollo/client/react. W starszych projektach spotkasz API wersji 3, w którym hooki importowało się prosto z @apollo/client, a klient przyjmował opcję uri.

Klient to łańcuch linków, przez które przechodzi każde zapytanie, oraz 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// Link HTTP do komunikacji z serwerem GraphQL
7const httpLink = new HttpLink({
8  uri: process.env.REACT_APP_GRAPHQL_ENDPOINT || 'http://localhost:4000/graphql',
9  credentials: 'include' // Dla cookies/session
10});
11
12// Link do obsługi autoryzacji
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 do obsługi błędów
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      // Obsługa specyficznych błędów
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    // Ponów żądanie po błędzie serwera
44    if (error.statusCode === 500) {
45      return forward(operation);
46    }
47  } else {
48    console.error(`Network error: ${error}`);
49  }
50});
51
52// Konfiguracja cache
53const cache = new InMemoryCache({
54  typePolicies: {
55    User: {
56      fields: {
57        // Konfiguracja cache dla pól użytkownika
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// Główny klient Apollo
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]) ustala kolejność: obsługa błędów, nagłówek autoryzacji, wysyłka HTTP. ErrorLink dostaje jeden obiekt error: CombinedGraphQLErrors.is(error) rozpoznaje błędy zwrócone przez GraphQL, a ServerError.is(error) odpowiedź HTTP z kodem błędu. W SetContextLink pierwszym argumentem jest poprzedni kontekst, stąd ({ headers }). InMemoryCache normalizuje odpowiedzi, czyli zapisuje każdy obiekt osobno pod kluczem z __typename i id, więc powtórne zapytanie o te same dane nie trafia do serwera. typePolicies decydują, jak łączyć listy.

Klienta udostępniasz całej aplikacji przez ApolloProvider, podobnie jak 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;

Każdy komponent wewnątrz providera korzysta teraz z hooków Apollo i wspólnego cache.

Wykonywanie zapytań (Queries)

useQuery wykonuje zapytanie automatycznie przy renderowaniu i zwraca data, loading oraz error. Zapytanie opisujesz szablonem gql, a zmienne podajesz w opcji variables:

1// Definicja zapytania GraphQL
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">Ładowanie użytkowników...</div>;
29  if (error) return <div className="error">Błąd: {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>Lista użytkowników</h2>
50      <button onClick={() => refetch()}>Odśwież</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>Posty: {user.posts.length}</p>
59          </div>
60        ))}
61      </div>
62
63      <button onClick={loadMoreUsers}>Załaduj więcej</button>
64    </div>
65  );
66}

Kolejność ma znaczenie: najpierw sprawdzasz loading, potem error, a dopiero na końcu renderujesz data. fetchMore dociąga kolejną stronę, a updateQuery dokleja nowych użytkowników do poprzednich.

Zapytania na żądanie

Wyszukiwarka powinna pytać serwer dopiero po kliknięciu. useLazyQuery zwraca funkcję, która uruchamia zapytanie na żądanie:

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">Szukaj</button>
38      </form>
39
40      {loading && <div>Wyszukiwanie...</div>}
41      {error && <div>Błąd wyszukiwania: {error.message}</div>}
42
43      {data?.searchUsers && (
44        <div>
45          <h3>Wyniki wyszukiwania:</h3>
46          {data.searchUsers.map(user => (
47            <div key={user.id}>{user.name} - {user.email}</div>
48          ))}
49        </div>
50      )}
51    </div>
52  );
53}

searchUsers wywołujesz w handleSearch, a stany loading i error działają jak w useQuery.

Mutacje (Mutations)

Mutacja zmienia dane na serwerze. W przeciwieństwie do useQuery, hook useMutation niczego nie wysyła sam, tylko zwraca funkcję, którą wywołujesz na żądanie, np. w 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    // Aktualizacja cache po utworzeniu posta
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    // Optymistyczna aktualizacja
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: 'Ty'
63        },
64        createdAt: new Date().toISOString()
65      }
66    },
67
68    // Obsługa błędów
69    onError: (error) => {
70      console.error('Błąd tworzenia posta:', error);
71    },
72
73    onCompleted: (data) => {
74      console.log('Post został utworzony:', 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('Wypełnij wszystkie pola');
85      return;
86    }
87
88    try {
89      await createPost({
90        variables: {
91          input: { title, content }
92        }
93      });
94    } catch (err) {
95      console.error('Błąd podczas tworzenia posta:', err);
96    }
97  };
98
99  return (
100    <form onSubmit={handleSubmit}>
101      <div>
102        <label>Tytuł:</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>Treść:</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 ? 'Tworzenie...' : 'Utwórz post'}
122      </button>
123
124      {error && <div className="error">Błąd: {error.message}</div>}
125    </form>
126  );
127}

update dopisuje nowy post do zapytania GET_POSTS w cache, więc lista odświeży się bez pytania serwera. optimisticResponse pokazuje post natychmiast, a gdy mutacja się nie uda, Apollo wycofa tę tymczasową wersję.

Zaawansowane zarządzanie cache

Przy usuwaniu posta cache trzeba posprzątać ręcznie, bo Apollo nie wie, które listy zawierały ten obiekt:

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    // Usuń z cache po usunięciu
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      // Usuń też z cache poszczególny post
38      cache.evict({ id: cache.identify({ __typename: 'Post', id: deletePost.id }) });
39      cache.gc();
40    }
41  });
42
43  const [updatePost] = useMutation(UPDATE_POST, {
44    // Nie potrzebujemy update - Apollo automatycznie zaktualizuje cache
45    // jeśli zwrócone dane mają to samo ID i __typename
46  });
47
48  const handleDelete = async () => {
49    if (window.confirm('Czy na pewno chcesz usunąć ten post?')) {
50      try {
51        await deletePost({
52          variables: { id: postId }
53        });
54      } catch (error) {
55        console.error('Błąd usuwania posta:', 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('Błąd aktualizacji posta:', error);
70    }
71  };
72
73  return (
74    <div>
75      <button onClick={handleDelete}>Usuń post</button>
76      {/* Formularz edycji */}
77    </div>
78  );
79}

cache.modify wycina referencję z listy posts, a evict i gc usuwają sam obiekt. Przy aktualizacji nic nie musisz robić: odpowiedź z tym samym id i __typename sama nadpisze obiekt w cache.

Subskrypcje (Subscriptions)

Subskrypcje przesyłają dane w czasie rzeczywistym przez WebSocket. ApolloLink.split kieruje je do GraphQLWsLink, a zapytania i mutacje do HTTP:

1// apolloClient.js - rozszerzenie konfiguracji
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 dla subskrypcji
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// Podział ruchu - HTTP dla queries/mutations, WebSocket dla subscriptions
25const splitLink = ApolloLink.split(
26  ({ operationType }) => operationType === OperationTypeNode.SUBSCRIPTION,
27  wsLink,
28  ApolloLink.from([errorLink, authLink, httpLink])
29);
30
31// Aktualizacja klienta
32const apolloClient = new ApolloClient({
33  link: splitLink, // Użyj splitLink zamiast ApolloLink.from([...])
34  cache,
35  // ... reszta konfiguracji
36});

W wersji 4 operacja ma pole operationType, więc test porównuje je z OperationTypeNode.SUBSCRIPTION z pakietu graphql. Pakiet graphql-ws to następca nieutrzymywanego subscriptions-transport-ws, więc wybieraj właśnie jego.

Komponent nasłuchuje zdarzeń hookiem useSubscription i dopisuje nowe posty do 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  // Subskrypcja na nowe posty
37  useSubscription(POST_ADDED_SUBSCRIPTION, {
38    onData: ({ data, client }) => {
39      const newPost = data.data.postAdded;
40
41      // Aktualizuj 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      // Pokazanie notyfikacji
51      showNotification(`Nowy post: ${newPost.title}`);
52    }
53  });
54
55  if (loading) return <div>Ładowanie...</div>;
56
57  return (
58    <div>
59      <h2>Na żywo - posty</h2>
60      {postsData?.posts.map(post => (
61        <LivePostCard key={post.id} post={post} />
62      ))}
63    </div>
64  );
65}
66
67function LivePostCard({ post }) {
68  // Subskrypcja na nowe komentarze dla tego posta
69  useSubscription(COMMENT_ADDED_SUBSCRIPTION, {
70    variables: { postId: post.id },
71    onData: ({ data }) => {
72      const newComment = data.data.commentAdded;
73      console.log(`Nowy komentarz w poście ${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>Autor: {post.author.name}</small>
82    </div>
83  );
84}

onData dostaje klienta i wynik subskrypcji, więc nowy post leży w data.data.postAdded. Starszą nazwę onSubscriptionData Apollo Client 4 usunął, więc taki callback już się nie wywoła.

Zaawansowane wzorce

Fragment to wielokrotnego użytku zestaw pól. USER_FRAGMENT definiujesz raz i wklejasz w każde zapytanie o użytkownika:

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// Użycie fragmentów
27const GET_POSTS_WITH_FRAGMENTS = gql`
28  query GetPosts {
29    posts {
30      ...PostInfo
31    }
32  }
33  ${POST_FRAGMENT}
34`;

Zmiana pól we fragmencie aktualizuje wszystkie zapytania, które go używają, a ${USER_FRAGMENT} dołącza jego definicję do dokumentu.

usePostOperations zamyka zapytanie, mutację i paginację w jednym hooku, więc komponent strony widzi proste 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// Użycie 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 nie wie nic o GraphQL. Gdy zmienisz schemat, poprawiasz jeden hook zamiast dziesięciu komponentów, i taki podział polecam.

GraphQLErrorBoundary łapie błąd renderowania i pokazuje ekran awaryjny z przyciskiem ponowienia, zamiast wywracać całe drzewo:

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    // Wyślij błąd do systemu monitorowania
23    this.logErrorToService(error, errorInfo);
24  }
25
26  logErrorToService(error, errorInfo) {
27    // Integracja z 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>Coś poszło nie tak z GraphQL</h2>
36          <details>
37            <summary>Szczegóły błędu</summary>
38            <pre>{this.state.error?.message}</pre>
39          </details>
40          <button onClick={() => this.setState({ hasError: false, error: null })}>
41            Spróbuj ponownie
42          </button>
43        </div>
44      );
45    }
46
47    return this.props.children;
48  }
49}
50
51// Użycie
52function App() {
53  return (
54    <ApolloProvider client={apolloClient}>
55      <GraphQLErrorBoundary>
56        <Router>
57          {/* Twoja aplikacja */}
58        </Router>
59      </GraphQLErrorBoundary>
60    </ApolloProvider>
61  );
62}

Apollo Client 4 nie ma już klasy ApolloError z wersji 3: błędy GraphQL rozpoznasz przez CombinedGraphQLErrors.is(error) (lista w error.errors), a odpowiedź HTTP z kodem błędu przez ServerError.is(error). Do granicy błędów trafią np. z useSuspenseQuery, bo useQuery nie rzuca błędu, tylko zwraca go w polu error.

Do testów służy MockedProvider, który zamiast serwera zwraca przygotowane odpowiedzi:

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  // Sprawdź loading state
38  expect(screen.getByText('Ładowanie użytkowników...')).toBeInTheDocument();
39
40  // Poczekaj na załadowanie danych
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(/Błąd: Network error/)).toBeInTheDocument();
65  });
66});

Mock musi pasować do zapytania i zmiennych co do joty, inaczej test dostanie błąd. W Apollo Client 4 MockedProvider importujesz z @apollo/client/testing/react, a opcji addTypename już nie ma: cache zawsze dopisuje __typename do zapytań, więc dane mocka też je zawierają i normalizacja działa jak w produkcji. Drugi test sprawdza, czy komunikat błędu trafia na ekran.

Kiedy używać Apollo Client?

Apollo Client sprawdza się, gdy backend używa GraphQL i potrzebujesz cache, który sam normalizuje i deduplikuje dane. Pomaga przy optymistycznych aktualizacjach, na przykład polubienie posta widać od razu, przy danych w czasie rzeczywistym przez subskrypcje, a Apollo DevTools pokazują w przeglądarce cache, zapytania i mutacje. Alternatywy to lżejszy urql z prostszym API, React Query z prostym klientem graphql-request oraz Relay od Mety, zoptymalizowany pod duże aplikacje. React Query, czyli TanStack Query, poznasz w jednej z kolejnych lekcji.

Pamiętaj: Apollo Client to system łączności statku, który nie tylko odbiera sygnały, ale też buforuje je w cache i rozsyła do każdego modułu, który ich potrzebuje.

Kod do tej lekcji: App.jsx
1import React, { useState, useEffect } from 'react';
2
3// Symulacja Apollo Client: zapytania, mutacja i znormalizowany cache
4// W prawdziwej aplikacji robią to ApolloClient, InMemoryCache, useQuery i useMutation
5
6// Dane po stronie "serwera" GraphQL
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: 'Komandor Nova', role: 'dowództwo', missionId: '1' },
15  { id: '2', name: 'Dr. Stellar', role: 'nauka', missionId: '1' },
16  { id: '3', name: 'Astro-7', role: 'technika', missionId: '2' },
17  { id: '4', name: 'Ra', role: 'nawigacja', missionId: '4' },
18];
19
20const STATUS_LABELS = { ACTIVE: 'Aktywna', PLANNING: 'W planach', COMPLETED: 'Zakończona', SUSPENDED: 'Wstrzymana' };
21const STATUS_COLORS = { ACTIVE: '#00ff88', PLANNING: '#ffa500', COMPLETED: '#64ffda', SUSPENDED: '#ff6b6b' };
22
23// Znormalizowany cache jak w InMemoryCache: każdy obiekt leży raz pod kluczem Typ:id,
24// a wynik zapytania przechowuje tylko listę kluczy
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// Zapytanie: najpierw cache, a dopiero przy braku danych "sieć"
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(`Nieznane zapytanie: ${queryName}`);
53}
54
55// Mutacja zmienia dane na serwerze i zwraca obiekt z id, więc cache
56// podmienia obiekt Mission:id, a każda lista z tym kluczem widzi nową wersję
57async function runMutation(mutationName, variables) {
58  await delay(500);
59  if (mutationName !== 'UPDATE_MISSION_STATUS') throw new Error(`Nieznana mutacja: ${mutationName}`);
60  const mission = serverMissions.find((item) => item.id === variables.id);
61  if (!mission) throw new Error(`Nie znaleziono misji ${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 symulujący useQuery: zwraca loading, data i 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}>Wczytywanie danych z serwera GraphQL...</div>;
95  if (error) return <div style={styles.error}>Błąd: {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}>Misje kosmiczne</h3>
101        <span style={fromCache ? styles.cacheBadge : styles.serverBadge}>{fromCache ? 'Z cache' : 'Z serwera'}</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' }}>Załoga: {mission.crew} | Postęp: {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}>Wczytywanie załogi...</p>;
127  if (data.length === 0) return <p style={styles.muted}>Brak przypisanej załogi.</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}>Mutacje GraphQL</h3>
146        <p style={styles.muted}>Wybierz misję z listy, żeby zobaczyć jej załogę i zmienić 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]}. Serwer zwrócił obiekt z id, więc cache podmienił Mission:${mission.id}, a lista odczytała go bez nowego zapytania.`);
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}>Mutacje GraphQL</h3>
169      <p style={{ ...styles.muted, marginTop: 0 }}>Załoga (zapytanie ze zmienną id):</p>
170      <MissionCrew missionId={selectedId} />
171      <p style={{ ...styles.muted, marginTop: 0 }}>Zmień status (mutacja):</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}>Wysyłanie mutacji...</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 - centrum dowodzenia GraphQL</h1>
194      <p style={styles.subtitle}>Symulacja zapytań, mutacji i znormalizowanego 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}>Po mutacji lista pokazuje plakietkę "Z cache": nowy status przyszedł w odpowiedzi mutacji i trafił do cache, więc zapytanie GET_MISSIONS nie musiało pytać serwera. W Apollo Client useQuery odświeżyłby listę sam, bo obserwuje 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;

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Jaką rolę pełni InMemoryCache w Apollo Client?

  2. 2. Czym różni się hook useMutation od useQuery w Apollo Client?

Zadania praktyczne w grze

  • Klikanie w kolejności

    Kliknij elementy w kolejności z lekcji: wywołanie useQuery, obsługa ładowania (loading), obsługa błędu (error) i wyświetlenie danych:

  • Klikanie w kolejności

    Ułóż składnię użycia hooka useQuery z Apollo Client:

  • Układanie w poziomie

    Ułóż elementy wywołania useQuery w Apollo Client od lewej do prawej:

Przydatne artykuły