Cours JavaScript et React · Module 11: Récupération de données et API

Projet principal : Catalogue cosmique des étoiles

7 min de lecture
Dans cette leçon3

Tu connais déjà chaque système du vaisseau séparément : l'émetteur (fetch), le panneau d'états, la protection contre les courses, la pagination. Mais pendant une vraie mission, ils fonctionnent tous en même temps, et c'est à la jonction des systèmes que les bugs apparaissent. Il est temps de combiner tout ce que tu as appris ! Dans ce projet, tu vas construire une application complète, le « Catalogue cosmique des étoiles » (Star Catalog), qui récupère des données depuis une API, gère les états de chargement et d'erreur, permet la recherche et prend en charge la pagination.

Exigences du projet

Ton application doit comprendre :

  1. Récupération de données depuis une API - utilise fetch() avec async/await
  2. État de chargement - affiche un spinner ou un squelette pendant le chargement
  3. Gestion des erreurs - affiche un message d'erreur avec une option de réessai
  4. État de données vides - gère la situation où il n'y a aucun résultat
  5. Recherche - filtre les résultats par nom avec debouncing
  6. Pagination - navigation entre les pages de résultats
  7. Custom hook useFetch - extrais la logique de récupération des données

Le seul concept nouveau est le debouncing : au lieu d'envoyer une requête à chaque frappe, on attend que l'utilisateur arrête de taper pendant une demi-seconde. Taper le mot « Luke » donne alors une seule requête, et non quatre.

Architecture de l'application

Avant d'écrire du code, dessine les modules du vaisseau. Chaque composant a une seule tâche, et toute la logique réseau se trouve dans un seul hook :

1// Structure des composants :
2// App
3// ├── SearchBar (champ de recherche)
4// ├── useFetch (custom hook)
5// ├── LoadingSpinner / ErrorMessage / EmptyState
6// ├── StarList (liste des résultats)
7// │   └── StarCard (résultat individuel)
8// └── Pagination (navigation entre les pages)

Dans notre implémentation, SearchBar et StarList sont écrits directement dans App sous la forme d'un <input> et d'une grille de cartes, par souci de concision. Quand l'application grandira, tu les extrairas sans changer la logique.

Implémentation

On construit de bas en haut, en commençant par le moteur. Le hook useFetch est le fetch sûr de la leçon sur les race conditions, avec un ajout : un compteur reloadKey. Il figure dans les dépendances de l'effet, donc l'incrémenter via refetch renvoie la requête à la même adresse :

1import React, { useState, useEffect } from 'react';
2
3// Custom Hook - useFetch
4function useFetch(url) {
5  const [data, setData] = useState(null);
6  const [loading, setLoading] = useState(true);
7  const [error, setError] = useState(null);
8  const [reloadKey, setReloadKey] = useState(0);
9
10  useEffect(() => {
11    if (!url) return;
12    const controller = new AbortController();
13
14    async function fetchData() {
15      try {
16        setLoading(true);
17        setError(null);
18        const response = await fetch(url, {
19          signal: controller.signal
20        });
21        if (!response.ok) {
22          throw new Error(`HTTP ${response.status}`);
23        }
24        const result = await response.json();
25        setData(result);
26      } catch (err) {
27        if (err.name !== 'AbortError') {
28          setError(err.message);
29        }
30      } finally {
31        if (!controller.signal.aborted) {
32          setLoading(false);
33        }
34      }
35    }
36
37    fetchData();
38    return () => controller.abort();
39  }, [url, reloadKey]);
40
41  // Changer reloadKey relance l'effet
42  const refetch = () => setReloadKey(k => k + 1);
43
44  return { data, loading, error, refetch };
45}

Quand url change (nouvelle page ou nouvelle recherche), le cleanup annule la requête précédente, donc une course ne peut pas écraser les résultats. Simplement définir la même valeur d'état, par exemple setPage(p => p), ne relancerait rien, car React saute le rendu quand l'état ne change pas. C'est pourquoi il faut un compteur séparé.

Viennent ensuite les trois panneaux d'état de la leçon sur le chargement et les erreurs. EmptyState reçoit la requête pour pouvoir dire ce qui n'a pas été trouvé :

1// Composants utilitaires
2function LoadingSpinner() {
3  return (
4    <div style={{ textAlign: 'center', padding: '40px' }}>
5      <div className="spinner"></div>
6      <p>Scan de la galaxie...</p>
7    </div>
8  );
9}
10
11function ErrorMessage({ message, onRetry }) {
12  return (
13    <div className="error-panel">
14      <h3>Liaison perdue !</h3>
15      <p>{message}</p>
16      <button onClick={onRetry}>Relancer le scan</button>
17    </div>
18  );
19}
20
21function EmptyState({ query }) {
22  return (
23    <div className="empty-panel">
24      <p>Aucun résultat {query ? `pour "${query}"` : ''}</p>
25      <p>Essaie une autre requête</p>
26    </div>
27  );
28}

Aucun de ces composants ne sait quoi que ce soit du réseau, ils reçoivent seulement des props. La carte d'étoile et la pagination sont tout aussi simples. Pagination n'a pas d'état propre, elle signale seulement un changement de page via onPageChange :

1function StarCard({ star }) {
2  return (
3    <div className="star-card">
4      <h3>{star.name}</h3>
5      <p>Taille : {star.height} cm</p>
6      <p>Poids : {star.mass} kg</p>
7      <p>Couleur des cheveux : {star.hair_color}</p>
8      <p>Année de naissance : {star.birth_year}</p>
9    </div>
10  );
11}
12
13function Pagination({ page, totalPages, onPageChange }) {
14  return (
15    <div className="pagination">
16      <button
17        onClick={() => onPageChange(page - 1)}
18        disabled={page === 1}
19      >
20        Précédente
21      </button>
22      <span>Page {page} sur {totalPages}</span>
23      <button
24        onClick={() => onPageChange(page + 1)}
25        disabled={page >= totalPages}
26      >
27        Suivante
28      </button>
29    </div>
30  );
31}

La condition page >= totalPages est plus sûre que ===, car elle fonctionne même quand une nouvelle recherche réduit le nombre de pages.

Enfin, la passerelle de commandement : App. Le debouncing est un effet avec setTimeout : chaque frappe efface le minuteur précédent, donc debouncedQuery ne change qu'après 500 ms de silence. On fait passer le texte de recherche par encodeURIComponent pour que les espaces et les caractères spéciaux ne cassent pas l'adresse :

1// Application principale
2function App() {
3  const [searchQuery, setSearchQuery] = useState('');
4  const [debouncedQuery, setDebouncedQuery] = useState('');
5  const [page, setPage] = useState(1);
6
7  // Debounce de la recherche
8  useEffect(() => {
9    const timer = setTimeout(() => {
10      setDebouncedQuery(searchQuery);
11      setPage(1); // Retour à la page 1 lors d'une nouvelle recherche
12    }, 500);
13    return () => clearTimeout(timer);
14  }, [searchQuery]);
15
16  // Construire l'URL avec les paramètres
17  const url = debouncedQuery
18    ? `https://swapi.dev/api/people/?search=${encodeURIComponent(debouncedQuery)}&page=${page}`
19    : `https://swapi.dev/api/people/?page=${page}`;
20
21  const { data, loading, error, refetch } = useFetch(url);
22  const totalPages = data ? Math.ceil(data.count / 10) : 1;
23
24  return (
25    <div className="app">
26      <h1>Catalogue cosmique des étoiles</h1>
27
28      <input
29        type="text"
30        value={searchQuery}
31        onChange={e => setSearchQuery(e.target.value)}
32        placeholder="Rechercher dans la galaxie..."
33        className="search-input"
34      />
35
36      {loading && <LoadingSpinner />}
37
38      {error && (
39        <ErrorMessage
40          message={error}
41          onRetry={refetch}
42        />
43      )}
44
45      {!loading && !error && data && (
46        <>
47          {data.results.length === 0 ? (
48            <EmptyState query={debouncedQuery} />
49          ) : (
50            <>
51              <p>Trouvé : {data.count} résultats</p>
52              <div className="star-grid">
53                {data.results.map(star => (
54                  <StarCard key={star.name} star={star} />
55                ))}
56              </div>
57              <Pagination
58                page={page}
59                totalPages={totalPages}
60                onPageChange={setPage}
61              />
62            </>
63          )}
64        </>
65      )}
66    </div>
67  );
68}
69
70export default App;

L'URL est calculée à partir de l'état à chaque rendu au lieu d'être stockée séparément, donc elle correspond toujours à page et debouncedQuery. Le bouton « Relancer le scan » appelle maintenant refetch et renvoie vraiment la requête. Une nouvelle recherche remet la page à 1, car la page 3 pour « Luke » n'existe généralement pas.

Ce projet combine toutes les techniques de ce module : l'API fetch, les états loading/error/empty, useEffect, le custom hook useFetch, AbortController, le debouncing et la pagination. Essaie de le construire toi-même, puis compare avec la solution ci-dessus. Mon conseil : commence par le hook et une seule liste, et ajoute la recherche à la fin. En bonus, tu peux remplacer useFetch par useQuery de TanStack Query et voir combien de code disparaît.

Retiens : une bonne application est un vaisseau dans lequel chaque module fait une seule chose, et le hook useFetch est son système de communication commun.

Code de cette leçon : App.jsx
1import React, { useState, useEffect } from 'react';
2
3// Custom Hook - useFetch
4function useFetch(url) {
5  const [data, setData] = useState(null);
6  const [loading, setLoading] = useState(true);
7  const [error, setError] = useState(null);
8
9  useEffect(() => {
10    if (!url) {
11      setLoading(false);
12      return;
13    }
14
15    const controller = new AbortController();
16
17    async function fetchData() {
18      try {
19        setLoading(true);
20        setError(null);
21
22        const response = await fetch(url, { signal: controller.signal });
23
24        if (!response.ok) {
25          throw new Error(`HTTP ${response.status} : ${response.statusText}`);
26        }
27
28        const result = await response.json();
29        setData(result);
30      } catch (err) {
31        if (err.name !== 'AbortError') {
32          setError(err.message);
33        }
34      } finally {
35        setLoading(false);
36      }
37    }
38
39    fetchData();
40    return () => controller.abort();
41  }, [url]);
42
43  return { data, loading, error };
44}
45
46// Composants utilitaires
47function LoadingSpinner() {
48  return (
49    <div className="state-box">
50      <div className="spinner"></div>
51      <p>Scan de la galaxie...</p>
52    </div>
53  );
54}
55
56function ErrorMessage({ message, onRetry }) {
57  return (
58    <div className="state-box error-box">
59      <h3>Liaison perdue !</h3>
60      <p>{message}</p>
61      <button className="retry-btn" onClick={onRetry}>Relancer le scan</button>
62    </div>
63  );
64}
65
66function EmptyState({ query }) {
67  return (
68    <div className="state-box empty-box">
69      <h3>Secteur vide</h3>
70      <p>Aucun résultat trouvé {query ? `pour "${query}"` : ''}</p>
71    </div>
72  );
73}
74
75function StarCard({ star }) {
76  return (
77    <div className="star-card">
78      <h3>{star.name}</h3>
79      <div className="star-stats">
80        <div className="stat">
81          <span className="stat-label">Taille</span>
82          <span className="stat-value">{star.height} cm</span>
83        </div>
84        <div className="stat">
85          <span className="stat-label">Masse</span>
86          <span className="stat-value">{star.mass} kg</span>
87        </div>
88        <div className="stat">
89          <span className="stat-label">Naiss.</span>
90          <span className="stat-value">{star.birth_year}</span>
91        </div>
92        <div className="stat">
93          <span className="stat-label">Cheveux</span>
94          <span className="stat-value">{star.hair_color}</span>
95        </div>
96      </div>
97    </div>
98  );
99}
100
101function Pagination({ page, totalPages, onPageChange }) {
102  return (
103    <div className="pagination">
104      <button
105        onClick={() => onPageChange(page - 1)}
106        disabled={page <= 1}
107      >
108        Précédente
109      </button>
110      <span className="page-info">Page {page} sur {totalPages}</span>
111      <button
112        onClick={() => onPageChange(page + 1)}
113        disabled={page >= totalPages}
114      >
115        Suivante
116      </button>
117    </div>
118  );
119}
120
121// Application principale
122function App() {
123  const [searchQuery, setSearchQuery] = useState('');
124  const [debouncedQuery, setDebouncedQuery] = useState('');
125  const [page, setPage] = useState(1);
126
127  // Debounce
128  useEffect(() => {
129    const timer = setTimeout(() => {
130      setDebouncedQuery(searchQuery);
131      setPage(1);
132    }, 500);
133    return () => clearTimeout(timer);
134  }, [searchQuery]);
135
136  // URL avec paramètres
137  const url = debouncedQuery
138    ? `https://swapi.dev/api/people/?search=${debouncedQuery}&page=${page}`
139    : `https://swapi.dev/api/people/?page=${page}`;
140
141  const { data, loading, error } = useFetch(url);
142  const totalPages = data ? Math.ceil(data.count / 10) : 1;
143
144  return (
145    <div className="app">
146      <h1>Catalogue cosmique des étoiles</h1>
147
148      <div className="search-bar">
149        <input
150          type="text"
151          value={searchQuery}
152          onChange={e => setSearchQuery(e.target.value)}
153          placeholder="Cherche dans la galaxie..."
154          className="search-input"
155        />
156      </div>
157
158      {loading && <LoadingSpinner />}
159
160      {error && (
161        <ErrorMessage
162          message={error}
163          onRetry={() => { setPage(1); setDebouncedQuery(debouncedQuery + ' '); }}
164        />
165      )}
166
167      {!loading && !error && data && (
168        <>
169          {data.results.length === 0 ? (
170            <EmptyState query={debouncedQuery} />
171          ) : (
172            <>
173              <p className="results-count">Trouvé : {data.count} résultats</p>
174              <div className="star-grid">
175                {data.results.map(star => (
176                  <StarCard key={star.name} star={star} />
177                ))}
178              </div>
179              {totalPages > 1 && (
180                <Pagination
181                  page={page}
182                  totalPages={totalPages}
183                  onPageChange={setPage}
184                />
185              )}
186            </>
187          )}
188        </>
189      )}
190    </div>
191  );
192}
193
194export default App;

Tu as repéré une erreur dans cette leçon ?

Articles utiles