Kurs JavaScript i React · Moduł 11: Pobieranie danych i API
Projekt Główny: Kosmiczny Katalog Gwiazd
W tej lekcji3
Znasz już osobno wszystkie systemy statku: nadajnik (fetch), panel stanów, ochronę przed wyścigami, paginację. Ale w prawdziwej misji działają one jednocześnie i to na styku systemów pojawiają się błędy. Czas połączyć wszystko, czego się nauczyłeś! W tym projekcie zbudujesz kompletną aplikację "Kosmiczny Katalog Gwiazd" (Star Catalog), która pobiera dane z API, obsługuje stany ładowania i błędów, umożliwia wyszukiwanie i wspiera paginację.
Wymagania projektu
Twoja aplikacja powinna zawierać:
- Pobieranie danych z API - użyj
fetch()z async/await - Stan ładowania - wyświetl spinner lub skeleton podczas ładowania
- Obsługa błędów - pokaż komunikat błędu z opcją ponownej próby
- Stan pustych danych - obsłuż sytuację, gdy nie ma wyników
- Wyszukiwanie - filtruj wyniki po nazwie z debouncingiem
- Paginacja - nawigacja między stronami wyników
- Custom hook useFetch - wyodrębnij logikę pobierania danych
Nowym pojęciem jest tylko debouncing: zamiast wysyłać zapytanie przy każdym naciśnięciu klawisza, czekamy, aż użytkownik przestanie pisać na pół sekundy. Wpisanie słowa "Luke" to wtedy jedno zapytanie, a nie cztery.
Architektura aplikacji
Zanim napiszesz kod, rozrysuj moduły statku. Każdy komponent ma jedno zadanie, a cała logika sieciowa siedzi w jednym hooku:
1// Struktura komponentów:
2// App
3// ├── SearchBar (input wyszukiwania)
4// ├── useFetch (custom hook)
5// ├── LoadingSpinner / ErrorMessage / EmptyState
6// ├── StarList (lista wyników)
7// │ └── StarCard (pojedynczy wynik)
8// └── Pagination (nawigacja stron)W naszej implementacji SearchBar i StarList są dla zwięzłości wpisane bezpośrednio w App jako <input> i siatka kart. Gdy aplikacja urośnie, wydzielisz je bez zmiany logiki.
Implementacja
Budujemy od dołu, zaczynając od silnika. Hook useFetch to znany z lekcji o race conditions bezpieczny fetch, z jednym dodatkiem: licznikiem reloadKey. Jest on w zależnościach efektu, więc jego zwiększenie przez refetch wysyła zapytanie ponownie pod ten sam adres:
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 // Zmiana reloadKey uruchamia efekt ponownie
42 const refetch = () => setReloadKey(k => k + 1);
43
44 return { data, loading, error, refetch };
45}Gdy zmieni się url (nowa strona lub nowe wyszukiwanie), cleanup anuluje poprzednie zapytanie, więc wyścig nie nadpisze wyników. Samo ustawienie tej samej wartości stanu, np. setPage(p => p), niczego by nie ponowiło, bo React pomija render, gdy stan się nie zmienia. Dlatego potrzebny jest osobny licznik.
Następnie trzy panele stanów z lekcji o ładowaniu i błędach. EmptyState dostaje zapytanie, żeby powiedzieć, czego nie znaleziono:
1// Komponenty pomocnicze
2function LoadingSpinner() {
3 return (
4 <div style={{ textAlign: 'center', padding: '40px' }}>
5 <div className="spinner"></div>
6 <p>Skanowanie galaktyki...</p>
7 </div>
8 );
9}
10
11function ErrorMessage({ message, onRetry }) {
12 return (
13 <div className="error-panel">
14 <h3>Utracono łączność!</h3>
15 <p>{message}</p>
16 <button onClick={onRetry}>Ponów skanowanie</button>
17 </div>
18 );
19}
20
21function EmptyState({ query }) {
22 return (
23 <div className="empty-panel">
24 <p>Brak wyników {query ? `dla "${query}"` : ''}</p>
25 <p>Spróbuj innego zapytania</p>
26 </div>
27 );
28}Żaden z tych komponentów nie wie nic o sieci, dostają tylko propsy. Karta gwiazdy i paginacja są równie proste. Pagination nie trzyma własnego stanu, tylko zgłasza zmianę strony przez onPageChange:
1function StarCard({ star }) {
2 return (
3 <div className="star-card">
4 <h3>{star.name}</h3>
5 <p>Wzrost: {star.height} cm</p>
6 <p>Waga: {star.mass} kg</p>
7 <p>Kolor wlosow: {star.hair_color}</p>
8 <p>Rok urodzenia: {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 Poprzednia
21 </button>
22 <span>Strona {page} z {totalPages}</span>
23 <button
24 onClick={() => onPageChange(page + 1)}
25 disabled={page >= totalPages}
26 >
27 Następna
28 </button>
29 </div>
30 );
31}Warunek page >= totalPages jest bezpieczniejszy niż ===, bo zadziała nawet wtedy, gdy nowe wyszukiwanie zmniejszy liczbę stron.
Na koniec mostek dowodzenia, czyli App. Debouncing to efekt z setTimeout: każde naciśnięcie klawisza sprząta poprzedni timer, więc debouncedQuery zmienia się dopiero po 500 ms ciszy. Tekst wyszukiwania przepuszczamy przez encodeURIComponent, żeby spacje i znaki specjalne nie zepsuły adresu:
1// Główna aplikacja
2function App() {
3 const [searchQuery, setSearchQuery] = useState('');
4 const [debouncedQuery, setDebouncedQuery] = useState('');
5 const [page, setPage] = useState(1);
6
7 // Debounce wyszukiwania
8 useEffect(() => {
9 const timer = setTimeout(() => {
10 setDebouncedQuery(searchQuery);
11 setPage(1); // Reset strony przy nowym wyszukiwaniu
12 }, 500);
13 return () => clearTimeout(timer);
14 }, [searchQuery]);
15
16 // Buduj URL z parametrami
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>Kosmiczny Katalog Gwiazd</h1>
27
28 <input
29 type="text"
30 value={searchQuery}
31 onChange={e => setSearchQuery(e.target.value)}
32 placeholder="Szukaj w galaktyce..."
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>Znaleziono: {data.count} wyników</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;Adres URL jest wyliczany przy każdym renderze ze stanu, a nie przechowywany osobno, więc zawsze pasuje do page i debouncedQuery. Przycisk "Ponów skanowanie" woła teraz refetch i naprawdę wysyła zapytanie jeszcze raz. Nowe wyszukiwanie resetuje stronę do 1, bo strona 3 dla "Luke" zwykle nie istnieje.
Ten projekt łączy wszystkie techniki z tego modułu: fetch API, stany loading/error/empty, useEffect, custom hook useFetch, AbortController, debouncing i paginację. Spróbuj zbudować go samodzielnie, a następnie porównaj z powyższym rozwiązaniem. Moja rada: zacznij od samego hooka i jednej listy, a wyszukiwanie dołóż na końcu. Jako rozszerzenie możesz podmienić useFetch na useQuery z TanStack Query i zobaczyć, ile kodu znika.
Pamiętaj: dobra aplikacja to statek, w którym każdy moduł robi jedno, a hook useFetch jest jego wspólnym systemem łączności.
Kod do tej lekcji: 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// Komponenty pomocnicze
47function LoadingSpinner() {
48 return (
49 <div className="state-box">
50 <div className="spinner"></div>
51 <p>Skanowanie galaktyki...</p>
52 </div>
53 );
54}
55
56function ErrorMessage({ message, onRetry }) {
57 return (
58 <div className="state-box error-box">
59 <h3>Utracono lacznosc!</h3>
60 <p>{message}</p>
61 <button className="retry-btn" onClick={onRetry}>Ponow skanowanie</button>
62 </div>
63 );
64}
65
66function EmptyState({ query }) {
67 return (
68 <div className="state-box empty-box">
69 <h3>Sektor pusty</h3>
70 <p>Nie znaleziono wynikow {query ? `dla "${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">Wzrost</span>
82 <span className="stat-value">{star.height} cm</span>
83 </div>
84 <div className="stat">
85 <span className="stat-label">Waga</span>
86 <span className="stat-value">{star.mass} kg</span>
87 </div>
88 <div className="stat">
89 <span className="stat-label">Ur.</span>
90 <span className="stat-value">{star.birth_year}</span>
91 </div>
92 <div className="stat">
93 <span className="stat-label">Wlosy</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 Poprzednia
109 </button>
110 <span className="page-info">Strona {page} z {totalPages}</span>
111 <button
112 onClick={() => onPageChange(page + 1)}
113 disabled={page >= totalPages}
114 >
115 Nastepna
116 </button>
117 </div>
118 );
119}
120
121// Glowna aplikacja
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 z parametrami
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>Kosmiczny Katalog Gwiazd</h1>
147
148 <div className="search-bar">
149 <input
150 type="text"
151 value={searchQuery}
152 onChange={e => setSearchQuery(e.target.value)}
153 placeholder="Szukaj w galaktyce..."
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">Znaleziono: {data.count} wynikow</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;Widzisz błąd w tej lekcji?