Cours JavaScript et React · Module 10: Écosystème et avenir de React
Apollo Client - Intégration complète de GraphQL avec React
Dans cette leçon6
Le centre de commandement interroge un serveur GraphQL sur les utilisateurs, leurs posts et leurs commentaires. Si chaque composant appelait fetch de son côté, les mêmes données arriveraient plusieurs fois, et après la modification d'un post certains écrans afficheraient l'ancienne version. Apollo Client envoie les requêtes GraphQL, garde les réponses dans un cache normalisé et rafraîchit les composants quand les données changent. Il gère aussi les mises à jour optimistes et les souscriptions.
Installation et configuration de base
Apollo Client 4 a besoin de trois paquets : @apollo/client, graphql et rxjs, car la version 4 s'appuie sur les observables de RxJS :
1npm install @apollo/client graphql rxjs
2# En option : souscriptions WebSocket et envoi de fichiers
3npm install graphql-ws apollo-upload-clientLes exemples de cette leçon utilisent l'API d'Apollo Client 4 : tu importes le client, le cache et les liens depuis @apollo/client, et ApolloProvider ainsi que les hooks comme useQuery depuis @apollo/client/react. Dans les projets plus anciens, tu rencontreras l'API de la version 3, où les hooks s'importaient directement depuis @apollo/client et où le client acceptait une option uri.
Le client est une chaîne de liens par lesquels passe chaque requête, plus un 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// Lien HTTP pour communiquer avec le serveur GraphQL
7const httpLink = new HttpLink({
8 uri: process.env.REACT_APP_GRAPHQL_ENDPOINT || 'http://localhost:4000/graphql',
9 credentials: 'include' // Pour les cookies/la session
10});
11
12// Lien pour gérer l'autorisation
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// Lien pour gérer les erreurs
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 // Gestion des erreurs spécifiques
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 // Nouvelle tentative après une erreur du serveur
44 if (error.statusCode === 500) {
45 return forward(operation);
46 }
47 } else {
48 console.error(`Network error: ${error}`);
49 }
50});
51
52// Configuration du cache
53const cache = new InMemoryCache({
54 typePolicies: {
55 User: {
56 fields: {
57 // Configuration du cache pour les champs de l'utilisateur
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// Client Apollo principal
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]) fixe l'ordre : gestion des erreurs, en-tête d'autorisation, envoi HTTP. ErrorLink reçoit un seul objet error : CombinedGraphQLErrors.is(error) reconnaît les erreurs renvoyées par GraphQL, et ServerError.is(error) une réponse HTTP avec un code d'erreur. Dans SetContextLink, le premier argument est le contexte précédent, d'où ({ headers }). InMemoryCache normalise les réponses, c'est-à-dire qu'il enregistre chaque objet séparément sous une clé formée de __typename et id, donc une nouvelle requête sur les mêmes données n'atteint pas le serveur. Les typePolicies décident comment fusionner les listes.
Tu rends le client disponible pour toute l'application via ApolloProvider, un peu comme un contexte :
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;Chaque composant à l'intérieur du provider utilise désormais les hooks d'Apollo et le cache partagé.
Exécuter des requêtes (Queries)
useQuery exécute une requête automatiquement lors du rendu et renvoie data, loading et error. Tu décris la requête avec le gabarit gql et tu passes les variables dans l'option variables :
1// Définition de la requête 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">Chargement des utilisateurs...</div>;
29 if (error) return <div className="error">Erreur : {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>Liste des utilisateurs</h2>
50 <button onClick={() => refetch()}>Rafraîchir</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}>Charger plus</button>
64 </div>
65 );
66}L'ordre compte : tu vérifies d'abord loading, puis error, et seulement à la fin tu rends data. fetchMore récupère la page suivante, et updateQuery ajoute les nouveaux utilisateurs aux précédents.
Requêtes à la demande
Un champ de recherche ne devrait interroger le serveur qu'après un clic. useLazyQuery renvoie une fonction qui lance la requête à la demande :
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">Rechercher</button>
38 </form>
39
40 {loading && <div>Recherche en cours...</div>}
41 {error && <div>Erreur de recherche : {error.message}</div>}
42
43 {data?.searchUsers && (
44 <div>
45 <h3>Résultats de la recherche :</h3>
46 {data.searchUsers.map(user => (
47 <div key={user.id}>{user.name} - {user.email}</div>
48 ))}
49 </div>
50 )}
51 </div>
52 );
53}Tu appelles searchUsers dans handleSearch, et les états loading et error fonctionnent comme dans useQuery.
Mutations
Une mutation modifie les données sur le serveur. Contrairement à useQuery, le hook useMutation n'envoie rien de lui-même, il renvoie seulement une fonction que tu appelles à la demande, par exemple dans 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 // Mise à jour du cache après la création d'un 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 // Mise à jour optimiste
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: 'Toi'
63 },
64 createdAt: new Date().toISOString()
65 }
66 },
67
68 // Gestion des erreurs
69 onError: (error) => {
70 console.error('Erreur lors de la création du post :', error);
71 },
72
73 onCompleted: (data) => {
74 console.log('Le post a été créé :', 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('Remplis tous les champs');
85 return;
86 }
87
88 try {
89 await createPost({
90 variables: {
91 input: { title, content }
92 }
93 });
94 } catch (err) {
95 console.error('Erreur pendant la création du post :', err);
96 }
97 };
98
99 return (
100 <form onSubmit={handleSubmit}>
101 <div>
102 <label>Titre :</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>Contenu :</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 ? 'Création...' : 'Créer le post'}
122 </button>
123
124 {error && <div className="error">Erreur : {error.message}</div>}
125 </form>
126 );
127}update ajoute le nouveau post à la requête GET_POSTS dans le cache, donc la liste se rafraîchit sans interroger le serveur. optimisticResponse affiche le post immédiatement, et si la mutation échoue, Apollo annule cette version temporaire.
Gestion avancée du cache
Lors de la suppression d'un post, il faut nettoyer le cache à la main, car Apollo ne sait pas quelles listes contenaient cet objet :
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 // Supprimer du cache après la suppression
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 // Supprimer aussi du cache le post individuel
38 cache.evict({ id: cache.identify({ __typename: 'Post', id: deletePost.id }) });
39 cache.gc();
40 }
41 });
42
43 const [updatePost] = useMutation(UPDATE_POST, {
44 // Pas besoin d'update - Apollo mettra automatiquement le cache à jour
45 // si les données renvoyées ont le même ID et le même __typename
46 });
47
48 const handleDelete = async () => {
49 if (window.confirm('Veux-tu vraiment supprimer ce post ?')) {
50 try {
51 await deletePost({
52 variables: { id: postId }
53 });
54 } catch (error) {
55 console.error('Erreur lors de la suppression du 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('Erreur lors de la mise à jour du post :', error);
70 }
71 };
72
73 return (
74 <div>
75 <button onClick={handleDelete}>Supprimer le post</button>
76 {/* Formulaire de modification */}
77 </div>
78 );
79}cache.modify retire la référence de la liste posts, et evict et gc suppriment l'objet lui-même. Pour une mise à jour, tu n'as rien à faire : une réponse avec le même id et le même __typename écrase d'elle-même l'objet dans le cache.
Souscriptions (Subscriptions)
Les souscriptions transmettent les données en temps réel via WebSocket. ApolloLink.split les dirige vers GraphQLWsLink, et les requêtes et mutations vers HTTP :
1// apolloClient.js - extension de la configuration
2import { OperationTypeNode } from 'graphql';
3import { ApolloLink } from '@apollo/client';
4import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
5import { createClient } from 'graphql-ws';
6
7// Lien WebSocket pour les souscriptions
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// Répartition du trafic - HTTP pour les queries/mutations, WebSocket pour les subscriptions
25const splitLink = ApolloLink.split(
26 ({ operationType }) => operationType === OperationTypeNode.SUBSCRIPTION,
27 wsLink,
28 ApolloLink.from([errorLink, authLink, httpLink])
29);
30
31// Client mis à jour
32const apolloClient = new ApolloClient({
33 link: splitLink, // Utiliser splitLink au lieu de ApolloLink.from([...])
34 cache,
35 // ... reste de la configuration
36});Dans la version 4, l'opération possède un champ operationType, donc le test le compare à OperationTypeNode.SUBSCRIPTION du paquet graphql. Le paquet graphql-ws est le successeur de subscriptions-transport-ws, qui n'est plus maintenu, donc choisis-le.
Un composant écoute les événements avec le hook useSubscription et ajoute les nouveaux posts au 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 // Souscription aux nouveaux posts
37 useSubscription(POST_ADDED_SUBSCRIPTION, {
38 onData: ({ data, client }) => {
39 const newPost = data.data.postAdded;
40
41 // Mettre à jour le 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 // Afficher une notification
51 showNotification(`Nouveau post : ${newPost.title}`);
52 }
53 });
54
55 if (loading) return <div>Chargement...</div>;
56
57 return (
58 <div>
59 <h2>Posts en direct</h2>
60 {postsData?.posts.map(post => (
61 <LivePostCard key={post.id} post={post} />
62 ))}
63 </div>
64 );
65}
66
67function LivePostCard({ post }) {
68 // Souscription aux nouveaux commentaires de ce post
69 useSubscription(COMMENT_ADDED_SUBSCRIPTION, {
70 variables: { postId: post.id },
71 onData: ({ data }) => {
72 const newComment = data.data.commentAdded;
73 console.log(`Nouveau commentaire sur le 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>Auteur : {post.author.name}</small>
82 </div>
83 );
84}onData reçoit le client et le résultat de la souscription, donc le nouveau post se trouve dans data.data.postAdded. Apollo Client 4 a supprimé l'ancien nom onSubscriptionData, donc un tel callback ne se déclenche plus.
Patterns avancés
Un fragment est un ensemble de champs réutilisable. Tu définis USER_FRAGMENT une seule fois et tu l'insères dans chaque requête sur un utilisateur :
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// Utilisation des fragments
27const GET_POSTS_WITH_FRAGMENTS = gql`
28 query GetPosts {
29 posts {
30 ...PostInfo
31 }
32 }
33 ${POST_FRAGMENT}
34`;Modifier les champs du fragment met à jour toutes les requêtes qui l'utilisent, et ${USER_FRAGMENT} joint sa définition au document.
usePostOperations regroupe une requête, une mutation et la pagination dans un seul hook, donc le composant de page voit une API simple :
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// Utilisation du 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 ne sait rien de GraphQL. Quand tu modifies le schéma, tu corriges un seul hook au lieu de dix composants, et je te recommande ce découpage.
GraphQLErrorBoundary attrape une erreur de rendu et affiche un écran de secours avec un bouton pour réessayer, au lieu de faire tomber tout l'arbre :
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 // Envoyer l'erreur au système de surveillance
23 this.logErrorToService(error, errorInfo);
24 }
25
26 logErrorToService(error, errorInfo) {
27 // Intégration avec 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>Quelque chose s'est mal passé avec GraphQL</h2>
36 <details>
37 <summary>Détails de l'erreur</summary>
38 <pre>{this.state.error?.message}</pre>
39 </details>
40 <button onClick={() => this.setState({ hasError: false, error: null })}>
41 Réessayer
42 </button>
43 </div>
44 );
45 }
46
47 return this.props.children;
48 }
49}
50
51// Utilisation
52function App() {
53 return (
54 <ApolloProvider client={apolloClient}>
55 <GraphQLErrorBoundary>
56 <Router>
57 {/* Ton application */}
58 </Router>
59 </GraphQLErrorBoundary>
60 </ApolloProvider>
61 );
62}Apollo Client 4 n'a plus la classe ApolloError de la version 3 : tu reconnais les erreurs GraphQL avec CombinedGraphQLErrors.is(error) (la liste se trouve dans error.errors), et une réponse HTTP avec un code d'erreur avec ServerError.is(error). Elles atteignent une error boundary depuis des hooks comme useSuspenseQuery, car useQuery ne lance pas l'erreur, il la renvoie dans le champ error.
Pour les tests, il y a MockedProvider, qui renvoie des réponses préparées à la place d'un serveur :
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 // Vérifier l'état de chargement
38 expect(screen.getByText('Chargement des utilisateurs...')).toBeInTheDocument();
39
40 // Attendre le chargement des données
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(/Erreur : Network error/)).toBeInTheDocument();
65 });
66});Le mock doit correspondre exactement à la requête et aux variables, sinon le test reçoit une erreur. Dans Apollo Client 4, tu importes MockedProvider depuis @apollo/client/testing/react, et l'option addTypename n'existe plus : le cache ajoute toujours __typename aux requêtes, donc les données du mock le contiennent aussi et la normalisation fonctionne comme en production. Le deuxième test vérifie que le message d'erreur arrive bien à l'écran.
Quand utiliser Apollo Client ?
Apollo Client est idéal quand le backend utilise GraphQL et que tu as besoin d'un cache qui normalise et déduplique les données tout seul. Il aide pour les mises à jour optimistes, par exemple un like sur un post est visible tout de suite, pour les données en temps réel via les souscriptions, et Apollo DevTools affichent dans le navigateur le cache, les requêtes et les mutations. Les alternatives sont urql, plus léger et avec une API plus simple, React Query avec le client simple graphql-request, et Relay de Meta, optimisé pour les grandes applications. Tu découvriras React Query, c'est-à-dire TanStack Query, dans l'une des prochaines leçons.
Retiens : Apollo Client est le système de communication du vaisseau, qui ne se contente pas de recevoir les signaux, mais les met aussi en mémoire dans le cache et les distribue à chaque module qui en a besoin.
Code de cette leçon : App.jsx
1import React, { useState, useEffect } from 'react';
2
3// Simulation d’Apollo Client : requêtes, mutation et cache normalisé
4// Dans une vraie appli, ApolloClient, InMemoryCache, useQuery et useMutation s’en chargent
5
6// Données côté « serveur » 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: 'Capitaine Nova', role: 'commandement', missionId: '1' },
15 { id: '2', name: 'Dr. Stellar', role: 'science', missionId: '1' },
16 { id: '3', name: 'Astro-7', role: 'ingénierie', missionId: '2' },
17 { id: '4', name: 'Ra', role: 'navigation', missionId: '4' },
18];
19
20const STATUS_LABELS = { ACTIVE: 'Active', PLANNING: 'En préparation', COMPLETED: 'Terminée', SUSPENDED: 'Suspendue' };
21const STATUS_COLORS = { ACTIVE: '#00ff88', PLANNING: '#ffa500', COMPLETED: '#64ffda', SUSPENDED: '#ff6b6b' };
22
23// Cache normalisé comme InMemoryCache : chaque objet est stocké une seule fois sous une clé Type:id,
24// et le résultat d’une requête ne garde qu’une liste de clés
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// Requête : d’abord le cache, et le « réseau » seulement s’il manque des données
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(`Requête inconnue : ${queryName}`);
53}
54
55// La mutation modifie les données sur le serveur et renvoie un objet avec un id, donc le cache
56// remplace l’objet Mission:id, et chaque liste qui contient cette clé voit la nouvelle version
57async function runMutation(mutationName, variables) {
58 await delay(500);
59 if (mutationName !== 'UPDATE_MISSION_STATUS') throw new Error(`Mutation inconnue : ${mutationName}`);
60 const mission = serverMissions.find((item) => item.id === variables.id);
61 if (!mission) throw new Error(`Mission introuvable : ${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 qui simule useQuery : renvoie loading, data et 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}>Chargement des données depuis le serveur GraphQL...</div>;
95 if (error) return <div style={styles.error}>Erreur : {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}>Missions spatiales</h3>
101 <span style={fromCache ? styles.cacheBadge : styles.serverBadge}>{fromCache ? 'Depuis le cache' : 'Depuis le serveur'}</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' }}>Équipage : {mission.crew} | Progression : {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}>Chargement de l’équipage...</p>;
127 if (data.length === 0) return <p style={styles.muted}>Aucun équipage assigné.</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}>Mutations GraphQL</h3>
146 <p style={styles.muted}>Sélectionne une mission dans la liste pour voir son équipage et changer son statut.</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]}. Le serveur a renvoyé un objet avec un id, donc le cache a remplacé Mission:${mission.id}, et la liste l’a lu sans nouvelle requête.`);
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}>Mutations GraphQL</h3>
169 <p style={{ ...styles.muted, marginTop: 0 }}>Équipage (requête avec une variable id) :</p>
170 <MissionCrew missionId={selectedId} />
171 <p style={{ ...styles.muted, marginTop: 0 }}>Changer le statut (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}>Envoi de la 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 - centre de contrôle GraphQL</h1>
194 <p style={styles.subtitle}>Simulation de requêtes, de mutations et d’un cache normalisé</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}>Après une mutation, la liste affiche le badge « Depuis le cache » : le nouveau statut est arrivé dans la réponse de la mutation et a été placé dans le cache, donc la requête GET_MISSIONS n’a pas eu besoin d’interroger le serveur. Dans Apollo Client, useQuery rafraîchirait la liste tout seul, car il observe le 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;Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Quel rôle joue InMemoryCache dans Apollo Client ?
2. En quoi le hook useMutation diffère-t-il de useQuery dans Apollo Client ?
Tâches pratiques dans le jeu
- Clique dans l'ordre
Clique sur les éléments dans l'ordre de la leçon : l'appel à useQuery, la gestion du chargement (loading), la gestion de l'erreur (error) puis l'affichage des données :
- Clique dans l'ordre
Compose la syntaxe d'utilisation du hook useQuery avec Apollo Client :
- Tri horizontal
Compose les éléments d'un appel à useQuery dans Apollo Client de gauche à droite :