Kurs JavaScript i React · Moduł 10: Ekosystem i przyszłość React
Apollo Client - kompleksowa integracja GraphQL z React
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-clientPrzykł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. Jaką rolę pełni InMemoryCache w Apollo Client?
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: