Cours JavaScript et React · Module 11: Récupération de données et API
Pagination et Infinite Scroll
Dans cette leçon3
Quand notre catalogue cosmique contient des milliers d'objets, on ne peut pas tous les récupérer d'un coup : cela surchargerait à la fois le serveur et le navigateur, et l'utilisateur ne verra de toute façon que les premiers. La solution est la pagination, qui découpe les données en portions plus petites (des pages). Dans cette leçon, tu vas découvrir deux façons dont un serveur découpe les données et trois façons dont l'interface les charge : des boutons de page, un bouton « Charger plus » et l'infinite scroll automatique.
Deux types de pagination
1. Pagination par offset (traditionnelle)
Le serveur renvoie un nombre défini d'éléments à partir d'un décalage (offset) donné. Le plus souvent, tu passes un numéro de page et une limite, et le serveur calcule l'offset avec (page - 1) * limit. L'API SWAPI que nous utilisons renvoie 10 éléments par page ainsi qu'un champ count avec le nombre total de résultats :
1// URL : /api/planets?page=1&limit=10
2// Renvoie les éléments 1-10
3
4// URL : /api/planets?page=2&limit=10
5// Renvoie les éléments 11-20
6
7function PaginatedList() {
8 const [data, setData] = useState([]);
9 const [page, setPage] = useState(1);
10 const [totalPages, setTotalPages] = useState(1);
11 const [loading, setLoading] = useState(true);
12
13 useEffect(() => {
14 async function fetchPage() {
15 setLoading(true);
16 const response = await fetch(
17 `https://swapi.dev/api/planets/?page=${page}`
18 );
19 const result = await response.json();
20 setData(result.results);
21 setTotalPages(Math.ceil(result.count / 10));
22 setLoading(false);
23 }
24
25 fetchPage();
26 }, [page]);Le numéro de page est un état ordinaire, et l'effet l'a dans ses dépendances, donc changer page déclenche à lui seul une nouvelle requête. On calcule le nombre de pages à partir de count avec Math.ceil, car la dernière page peut être incomplète. La seconde partie du composant, c'est la liste et les boutons :
1 return (
2 <div>
3 {loading ? (
4 <p>Chargement de la page {page}...</p>
5 ) : (
6 data.map(planet => (
7 <div key={planet.name}>{planet.name}</div>
8 ))
9 )}
10
11 <div className="pagination">
12 <button
13 onClick={() => setPage(p => p - 1)}
14 disabled={page === 1}
15 >
16 Précédente
17 </button>
18 <span>Page {page} sur {totalPages}</span>
19 <button
20 onClick={() => setPage(p => p + 1)}
21 disabled={page === totalPages}
22 >
23 Suivante
24 </button>
25 </div>
26 </div>
27 );
28}Les boutons se désactivent aux extrémités, donc on ne demande jamais la page 0. Chaque page remplace la précédente dans data au lieu de s'y ajouter. Par souci de concision, on a omis ici la gestion des erreurs et AbortController des leçons précédentes, ajoute-les dans du vrai code.
2. Pagination par curseur
Au lieu d'un numéro de page, le serveur renvoie un curseur, c'est-à-dire un pointeur vers la portion suivante de données (par exemple l'id du dernier élément). Elle est plus performante pour les grands ensembles de données, car la base n'a pas à sauter des milliers de lignes, et elle reste stable quand quelqu'un ajoute de nouveaux éléments entre-temps :
1function CursorPaginatedList() {
2 const [items, setItems] = useState([]);
3 const [nextCursor, setNextCursor] = useState(null);
4 const [loading, setLoading] = useState(false);
5
6 const loadMore = async () => {
7 setLoading(true);
8 const url = nextCursor
9 ? `/api/stars?cursor=${nextCursor}&limit=20`
10 : '/api/stars?limit=20';
11
12 const response = await fetch(url);
13 const data = await response.json();
14
15 setItems(prev => [...prev, ...data.items]);
16 setNextCursor(data.nextCursor); // null s'il n'y a plus rien
17 setLoading(false);
18 };
19
20 useEffect(() => {
21 loadMore();
22 }, []);
23
24 return (
25 <div>
26 {items.map(item => (
27 <div key={item.id}>{item.name}</div>
28 ))}
29 {nextCursor && (
30 <button onClick={loadMore} disabled={loading}>
31 {loading ? 'Chargement...' : 'Charger plus'}
32 </button>
33 )}
34 </div>
35 );
36}Ici, on ajoute les nouveaux éléments aux anciens avec le spread : [...prev, ...data.items]. Quand le serveur renvoie un nextCursor égal à null, le bouton disparaît, car il n'y a plus rien à charger. L'inconvénient, c'est qu'on ne peut pas sauter « à la page 7 », donc les curseurs conviennent aux listes défilantes et l'offset aux tableaux avec numéros de page.
Le bouton « Charger plus »
SWAPI utilise une variante pratique : sa réponse contient un champ next avec l'adresse toute prête de la page suivante, ou null à la fin. On garde donc simplement l'URL suivante dans l'état :
1function LoadMoreList() {
2 const [planets, setPlanets] = useState([]);
3 const [nextUrl, setNextUrl] = useState(
4 'https://swapi.dev/api/planets/'
5 );
6 const [loading, setLoading] = useState(false);
7
8 const loadMore = async () => {
9 if (!nextUrl || loading) return;
10
11 setLoading(true);
12 try {
13 const response = await fetch(nextUrl);
14 const data = await response.json();
15
16 setPlanets(prev => [...prev, ...data.results]);
17 setNextUrl(data.next); // URL de la page suivante ou null
18 } catch (err) {
19 console.error(err);
20 } finally {
21 setLoading(false);
22 }
23 };
24
25 useEffect(() => {
26 loadMore();
27 }, []);
28
29 return (
30 <div>
31 <h2>Catalogue des planètes ({planets.length})</h2>
32 {planets.map(planet => (
33 <div key={planet.name} className="planet-card">
34 <h3>{planet.name}</h3>
35 <p>Climat : {planet.climate}</p>
36 </div>
37 ))}
38 {nextUrl && (
39 <button onClick={loadMore} disabled={loading}>
40 {loading ? 'Scan en cours...' : 'Découvrir plus de planètes'}
41 </button>
42 )}
43 {!nextUrl && planets.length > 0 && (
44 <p>Toute la galaxie a été scannée !</p>
45 )}
46 </div>
47 );
48}La garde if (!nextUrl || loading) return protège contre un double clic et contre la demande d'une page qui n'existe pas. En mode développement avec StrictMode, l'effet initial s'exécute deux fois et la première page peut être dupliquée. La solution est le flag ignore ou AbortController, vus dans la leçon sur les race conditions.
Infinite Scroll avec IntersectionObserver
L'infinite scroll charge les données automatiquement quand l'utilisateur atteint la fin de la liste. Il s'appuie sur IntersectionObserver, une API native du navigateur qui te prévient quand un élément observé entre dans la zone visible (le viewport). On garde l'observateur dans useRef pour qu'il survive aux rendus, et on attache au dernier élément un ref callback, une fonction que React appelle avec le nœud DOM :
1import { useRef, useCallback } from 'react';
2
3function InfiniteScrollList() {
4 const [items, setItems] = useState([]);
5 const [page, setPage] = useState(1);
6 const [hasMore, setHasMore] = useState(true);
7 const [loading, setLoading] = useState(false);
8
9 const observer = useRef();
10
11 // Ref callback - se déclenche quand le dernier élément
12 // apparaît à l'écran
13 const lastItemRef = useCallback(node => {
14 if (loading) return;
15
16 // Déconnecter l'observer précédent
17 if (observer.current) observer.current.disconnect();
18
19 observer.current = new IntersectionObserver(entries => {
20 if (entries[0].isIntersecting && hasMore) {
21 setPage(prev => prev + 1);
22 }
23 });
24
25 if (node) observer.current.observe(node);
26 }, [loading, hasMore]);useCallback fait en sorte que le ref callback ne change qu'avec loading ou hasMore. React détache alors l'ancienne fonction et attache la nouvelle, et nous déconnectons l'observateur précédent avec disconnect(). Depuis React 19, un ref callback peut aussi retourner une fonction de nettoyage. La récupération elle-même te semblera familière :
1 useEffect(() => {
2 async function loadPage() {
3 setLoading(true);
4 const response = await fetch(
5 `https://swapi.dev/api/people/?page=${page}`
6 );
7 const data = await response.json();
8
9 setItems(prev => [...prev, ...data.results]);
10 setHasMore(data.next !== null);
11 setLoading(false);
12 }
13
14 loadPage();
15 }, [page]);
16
17 return (
18 <div>
19 {items.map((item, index) => {
20 // Attacher le ref au dernier élément
21 if (index === items.length - 1) {
22 return (
23 <div ref={lastItemRef} key={item.name}>
24 {item.name}
25 </div>
26 );
27 }
28 return <div key={item.name}>{item.name}</div>;
29 })}
30 {loading && <p>Chargement de la suite...</p>}
31 {!hasMore && <p>Fin de la liste</p>}
32 </div>
33 );
34}Quand le dernier élément de la liste devient visible, l'observateur incrémente page, l'effet récupère la page suivante et l'ajoute à la liste. setHasMore(data.next !== null) arrête tout le mécanisme à la fin des données. Je te recommande l'infinite scroll pour la navigation libre, comme un fil d'actualité, mais pour un catalogue où l'on veut revenir à un endroit précis, les pages fonctionnent mieux. Dans le projet final, tu utiliseras justement la pagination avec boutons.
Retiens : une galaxie se scanne secteur par secteur, car personne ne télécharge l'univers entier en une seule requête.
Code de cette leçon : App.jsx
1import React, { useState, useEffect, useRef, useCallback } from 'react';
2
3function PaginatedExplorer() {
4 const [mode, setMode] = useState('pages'); // 'pages' or 'infinite'
5
6 return (
7 <div className="app">
8 <h1>Catalogue cosmique</h1>
9 <div className="mode-toggle">
10 <button
11 className={"mode-btn" + (mode === 'pages' ? " active" : "")}
12 onClick={() => setMode('pages')}
13 >
14 Pagination
15 </button>
16 <button
17 className={"mode-btn" + (mode === 'infinite' ? " active" : "")}
18 onClick={() => setMode('infinite')}
19 >
20 Infinite Scroll
21 </button>
22 </div>
23 {mode === 'pages' ? <PagedList /> : <InfiniteList />}
24 </div>
25 );
26}
27
28// Pagination traditionnelle
29function PagedList() {
30 const [data, setData] = useState(null);
31 const [page, setPage] = useState(1);
32 const [loading, setLoading] = useState(true);
33
34 useEffect(() => {
35 setLoading(true);
36 fetch(`https://swapi.dev/api/people/?page=${page}`)
37 .then(res => res.json())
38 .then(result => {
39 setData(result);
40 setLoading(false);
41 })
42 .catch(() => setLoading(false));
43 }, [page]);
44
45 const totalPages = data ? Math.ceil(data.count / 10) : 1;
46
47 return (
48 <div>
49 {loading ? (
50 <div className="loading"><div className="spinner"></div></div>
51 ) : data ? (
52 <>
53 <p className="count">Trouvé : {data.count} personnages</p>
54 <div className="card-list">
55 {data.results.map(person => (
56 <div key={person.name} className="card">
57 <h3>{person.name}</h3>
58 <p>Taille : {person.height}cm | Masse : {person.mass}kg</p>
59 <p>Année de naissance : {person.birth_year}</p>
60 </div>
61 ))}
62 </div>
63 <div className="pagination">
64 <button onClick={() => setPage(p => p - 1)} disabled={page === 1}>
65 Précédente
66 </button>
67 <span>Page {page} sur {totalPages}</span>
68 <button onClick={() => setPage(p => p + 1)} disabled={page >= totalPages}>
69 Suivante
70 </button>
71 </div>
72 </>
73 ) : null}
74 </div>
75 );
76}
77
78// Infinite Scroll
79function InfiniteList() {
80 const [items, setItems] = useState([]);
81 const [page, setPage] = useState(1);
82 const [hasMore, setHasMore] = useState(true);
83 const [loading, setLoading] = useState(false);
84 const observer = useRef();
85
86 const lastItemRef = useCallback(node => {
87 if (loading) return;
88 if (observer.current) observer.current.disconnect();
89
90 observer.current = new IntersectionObserver(entries => {
91 if (entries[0].isIntersecting && hasMore) {
92 setPage(prev => prev + 1);
93 }
94 });
95
96 if (node) observer.current.observe(node);
97 }, [loading, hasMore]);
98
99 useEffect(() => {
100 setLoading(true);
101 fetch(`https://swapi.dev/api/people/?page=${page}`)
102 .then(res => res.json())
103 .then(result => {
104 setItems(prev => [...prev, ...result.results]);
105 setHasMore(result.next !== null);
106 setLoading(false);
107 })
108 .catch(() => setLoading(false));
109 }, [page]);
110
111 return (
112 <div>
113 <p className="count">Chargé : {items.length} personnages</p>
114 <div className="card-list">
115 {items.map((person, index) => (
116 <div
117 key={person.name + index}
118 className="card"
119 ref={index === items.length - 1 ? lastItemRef : null}
120 >
121 <h3>{person.name}</h3>
122 <p>Taille : {person.height}cm | Masse : {person.mass}kg</p>
123 <p>Année de naissance : {person.birth_year}</p>
124 </div>
125 ))}
126 </div>
127 {loading && <div className="loading"><div className="spinner"></div></div>}
128 {!hasMore && <p className="end">Fin de la liste - toute la galaxie a été scannée !</p>}
129 </div>
130 );
131}
132
133export default PaginatedExplorer;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. Quelle est la principale différence entre la pagination par offset (page/limit) et la pagination par curseur (cursor) ?
2. Quelle propriété d'une réponse d'API paginée contient l'URL pour récupérer la page de résultats suivante ?
Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Tri horizontal
Ordonne la syntaxe pour ajouter de nouveaux éléments à ceux existants dans la pagination (spread operator) :
- Tri vertical
Ordonne les étapes pour implémenter l'infinite scroll avec IntersectionObserver :
- Éditeur de code
Pagination avec des boutons
- Éditeur de code
Le bouton Charger plus
- Tri vertical
Ordonne les éléments du pattern complet de data fetching en React (du début à la fin) :
- Tri horizontal
Ordonne la syntaxe pour vérifier s'il y a d'autres pages à récupérer :
- Clique dans l'ordre
Clique sur les éléments dans l'ordre pour créer un IntersectionObserver en React :
- Tri vertical
Ordonne les codes de statut HTTP du succès à l'erreur la plus grave :
- Tri horizontal
Ordonne la syntaxe de debouncing avec setTimeout dans useEffect :
- Tri vertical
Ordonne les techniques de data fetching de la plus simple à la plus avancée :