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

Projekt Główny: Kosmiczny Katalog Gwiazd

6 min czytania
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ć:

  1. Pobieranie danych z API - użyj fetch() z async/await
  2. Stan ładowania - wyświetl spinner lub skeleton podczas ładowania
  3. Obsługa błędów - pokaż komunikat błędu z opcją ponownej próby
  4. Stan pustych danych - obsłuż sytuację, gdy nie ma wyników
  5. Wyszukiwanie - filtruj wyniki po nazwie z debouncingiem
  6. Paginacja - nawigacja między stronami wyników
  7. 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?

Przydatne artykuły