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

Pagination et Infinite Scroll

7 min de lecture
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. 1. Quelle est la principale différence entre la pagination par offset (page/limit) et la pagination par curseur (cursor) ?

  2. 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 :

Articles utiles