Kurs JavaScript i React · Moduł 11: Pobieranie danych i API
Paginacja i Infinite Scroll
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. Jaka jest główna różnica między paginacją offsetową (page/limit) a kursorową (cursor)?
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: