Kurs JavaScript i React · Moduł 11: Pobieranie danych i API

Paginacja i Infinite Scroll

6 min czytania
W tej lekcji3

Gdy nasz kosmiczny katalog zawiera tysiące obiektów, nie możemy pobrać ich wszystkich naraz: to przeciążyłoby zarówno serwer, jak i przeglądarkę, a użytkownik i tak zobaczy najpierw tylko kilka pierwszych. Rozwiązaniem jest paginacja, czyli dzielenie danych na mniejsze porcje (strony). W tej lekcji poznasz dwa sposoby, w jakie serwer dzieli dane, i trzy sposoby, w jakie interfejs je doczytuje: przyciski stron, przycisk "Załaduj więcej" i automatyczny infinite scroll.

Dwa typy paginacji

1. Paginacja offsetowa (tradycyjna)

Serwer zwraca określoną liczbę elementów, począwszy od konkretnego przesunięcia (offsetu). Najczęściej podajesz numer strony i limit, a serwer liczy offset jako (page - 1) * limit. API SWAPI, którego używamy, zwraca po 10 elementów na stronę oraz pole count z liczbą wszystkich wyników:

1// URL: /api/planets?page=1&limit=10
2// Zwraca elementy 1-10
3
4// URL: /api/planets?page=2&limit=10
5// Zwraca elementy 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]);

Numer strony jest zwykłym stanem, a efekt ma go w zależnościach, więc zmiana page sama wywołuje nowe zapytanie. Liczbę stron wyliczamy z count przez Math.ceil, bo ostatnia strona może być niepełna. Druga część komponentu to lista i przyciski:

1  return (
2    <div>
3      {loading ? (
4        <p>Ładowanie strony {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          Poprzednia
17        </button>
18        <span>Strona {page} z {totalPages}</span>
19        <button
20          onClick={() => setPage(p => p + 1)}
21          disabled={page === totalPages}
22        >
23          Następna
24        </button>
25      </div>
26    </div>
27  );
28}

Przyciski blokują się na krańcach, więc nie poprosimy o stronę 0. Każda strona zastępuje poprzednią w data, a nie dopisuje się do niej. Dla zwięzłości pominęliśmy tu obsługę błędów i AbortController z poprzednich lekcji, w prawdziwym kodzie je dodaj.

2. Paginacja kursorowa

Zamiast numeru strony serwer zwraca kursor, czyli wskaźnik do następnej porcji danych (np. id ostatniego elementu). Jest wydajniejsza dla dużych zbiorów danych, bo baza nie musi przeskakiwać tysięcy wierszy, i stabilna, gdy w międzyczasie ktoś doda nowe elementy:

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 jeżeli brak następnych
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 ? 'Ładowanie...' : 'Załaduj więcej'}
32        </button>
33      )}
34    </div>
35  );
36}

Tu nowe elementy dopisujemy do starych przez spread: [...prev, ...data.items]. Gdy serwer zwróci nextCursor równe null, przycisk znika, bo nie ma już czego ładować. Minusem jest brak skoku "na stronę 7", dlatego kursory pasują do list przewijanych, a offset do tabel z numerami stron.

Przycisk "Załaduj więcej"

SWAPI stosuje wygodny wariant: w odpowiedzi zwraca pole next z gotowym adresem następnej strony albo null na końcu. Trzymamy więc w stanie po prostu następny URL:

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 następnej strony lub 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>Katalog planet ({planets.length})</h2>
32      {planets.map(planet => (
33        <div key={planet.name} className="planet-card">
34          <h3>{planet.name}</h3>
35          <p>Klimat: {planet.climate}</p>
36        </div>
37      ))}
38      {nextUrl && (
39        <button onClick={loadMore} disabled={loading}>
40          {loading ? 'Skanowanie...' : 'Odkryj więcej planet'}
41        </button>
42      )}
43      {!nextUrl && planets.length > 0 && (
44        <p>Przeskanowano całą galaktykę!</p>
45      )}
46    </div>
47  );
48}

Strażnik if (!nextUrl || loading) return chroni przed podwójnym kliknięciem i przed pytaniem o nieistniejącą stronę. W trybie deweloperskim ze StrictMode efekt startowy uruchomi się dwa razy i pierwsza strona może się zdublować. Rozwiązaniem jest flaga ignore albo AbortController z lekcji o race conditions.

Infinite Scroll z IntersectionObserver

Infinite scroll ładuje dane automatycznie, gdy użytkownik dojedzie do końca listy. Pomaga w tym IntersectionObserver, natywne API przeglądarki, które powiadamia, gdy obserwowany element pojawi się w widocznym obszarze (viewporcie). Obserwatora trzymamy w useRef, żeby przetrwał rendery, a do ostatniego elementu podpinamy ref callback, czyli funkcję, którą React wywołuje z węzłem 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 - uruchomi się gdy ostatni element
12  // pojawi się na ekranie
13  const lastItemRef = useCallback(node => {
14    if (loading) return;
15
16    // Odłącz poprzedni observer
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 sprawia, że ref callback zmienia się tylko razem z loading lub hasMore. Wtedy React odpina starą funkcję i podpina nową, a my rozłączamy poprzedniego obserwatora przez disconnect(). Od React 19 ref callback może też zwrócić funkcję sprzątającą. Samo pobieranie wygląda znajomo:

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        // Przypnij ref do ostatniego elementu
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>Ładowanie kolejnych...</p>}
31      {!hasMore && <p>Koniec listy</p>}
32    </div>
33  );
34}

Gdy ostatni element listy staje się widoczny, obserwator zwiększa page, efekt pobiera następną stronę i dopisuje ją do listy. setHasMore(data.next !== null) zatrzymuje cały mechanizm na końcu danych. Infinite scroll polecam do przeglądania, jak w kanale aktualności, ale do katalogu, w którym ktoś chce wrócić do konkretnego miejsca, lepiej sprawdzą się strony. W projekcie końcowym użyjesz właśnie paginacji z przyciskami.

Pamiętaj: galaktykę skanuje się sektor po sektorze, bo nikt nie pobiera całego wszechświata jednym zapytaniem.

Kod do tej lekcji: 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>Kosmiczny Katalog</h1>
9      <div className="mode-toggle">
10        <button
11          className={"mode-btn" + (mode === 'pages' ? " active" : "")}
12          onClick={() => setMode('pages')}
13        >
14          Paginacja
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// Paginacja tradycyjna
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">Znaleziono: {data.count} postaci</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>Wzrost: {person.height}cm | Waga: {person.mass}kg</p>
59                <p>Rok ur.: {person.birth_year}</p>
60              </div>
61            ))}
62          </div>
63          <div className="pagination">
64            <button onClick={() => setPage(p => p - 1)} disabled={page === 1}>
65              Poprzednia
66            </button>
67            <span>Strona {page} z {totalPages}</span>
68            <button onClick={() => setPage(p => p + 1)} disabled={page >= totalPages}>
69              Nastepna
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">Zaladowano: {items.length} postaci</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>Wzrost: {person.height}cm | Waga: {person.mass}kg</p>
123            <p>Rok ur.: {person.birth_year}</p>
124          </div>
125        ))}
126      </div>
127      {loading && <div className="loading"><div className="spinner"></div></div>}
128      {!hasMore && <p className="end">Koniec listy - przeskanowano cala galaktyke!</p>}
129    </div>
130  );
131}
132
133export default PaginatedExplorer;

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Jaka jest główna różnica między paginacją offsetową (page/limit) a kursorową (cursor)?

  2. 2. Która właściwość odpowiedzi API z paginacją zawiera URL do pobrania następnej strony wyników?

To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Układanie w poziomie

    Ułóż składnię dodawania nowych elementów do istniejących w paginacji (spread operator):

  • Układanie w pionie

    Ułóż kolejność kroków implementacji infinite scroll z IntersectionObserver:

  • Edytor kodu

    Paginacja z przyciskami

  • Edytor kodu

    Przycisk Load More

  • Układanie w pionie

    Ułóż kolejność elementów pełnego wzorca data fetchingu w React (od początku do końca):

  • Układanie w poziomie

    Ułóż składnię sprawdzenia, czy są kolejne strony do pobrania:

  • Klikanie w kolejności

    Kliknij elementy w kolejności tworzenia IntersectionObserver w React:

  • Układanie w pionie

    Ułóż kody statusu HTTP od sukcesu do najpoważniejszego błędu:

  • Układanie w poziomie

    Ułóż składnię debouncing z setTimeout w useEffect:

  • Układanie w pionie

    Ułóż techniki data fetchingu od najprostszej do najbardziej zaawansowanej:

Przydatne artykuły