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

Stany ładowania, błędów i pustych danych

5 min czytania
W tej lekcji4

Wyobraź sobie panel na mostku, który po wysłaniu zapytania po prostu gaśnie. Nie wiesz, czy dane są w drodze, czy transmisja się urwała, czy Centrum Dowodzenia nie ma nic do przekazania. Użytkownik aplikacji czuje dokładnie to samo, gdy widzi pusty ekran. Każde zapytanie do serwera kończy się jedną z czterech sytuacji: dane są w drodze (ładowanie), przesyłanie się nie powiodło (błąd), centrum nie ma żadnych danych (pusty wynik) albo dane dotarły (sukces). Każda z nich potrzebuje własnego widoku.

Wzorzec Loading/Error/Data

Najpopularniejszy wzorzec w React opiera się na trzech zmiennych stanu z useState: data na wynik, loading jako flaga "czekamy" i error na komunikat błędu. Zapytanie wysyłamy w useEffect, a blok try/catch/finally pilnuje, żeby każda ścieżka zaktualizowała właściwy stan. Najpierw część, która pobiera dane:

1import React, { useState, useEffect } from 'react';
2
3function StarshipCatalog() {
4  const [data, setData] = useState(null);
5  const [loading, setLoading] = useState(true);
6  const [error, setError] = useState(null);
7
8  useEffect(() => {
9    async function fetchStarships() {
10      try {
11        setLoading(true);
12        setError(null);
13
14        const response = await fetch(
15          'https://swapi.dev/api/starships/'
16        );
17
18        if (!response.ok) {
19          throw new Error(`HTTP Error: ${response.status}`);
20        }
21
22        const result = await response.json();
23        setData(result.results);
24      } catch (err) {
25        setError(err.message);
26      } finally {
27        setLoading(false);
28      }
29    }
30
31    fetchStarships();
32  }, []);

loading startuje od true, bo zapytanie wyrusza zaraz po pierwszym renderze. Blok finally wykona się zarówno po sukcesie, jak i po błędzie, więc flaga ładowania nigdy nie "utknie". Zauważ też ręczne throw dla !response.ok: bez niego odpowiedź 500 trafiłaby do setData jak sukces.

Druga połowa tego samego komponentu decyduje, co pokazać. Kolejność warunków jest ważna: najpierw ładowanie, potem błąd, potem pusty wynik, a dopiero na końcu dane:

1  // Stan ładowania
2  if (loading) {
3    return <div className="loading">Skanowanie sektora...</div>;
4  }
5
6  // Stan błędu
7  if (error) {
8    return <div className="error">Błąd komunikacji: {error}</div>;
9  }
10
11  // Stan pustych danych
12  if (!data || data.length === 0) {
13    return <div className="empty">Brak statków w tym sektorze</div>;
14  }
15
16  // Dane gotowe do wyświetlenia
17  return (
18    <div>
19      <h2>Wykryte statki: {data.length}</h2>
20      {data.map(ship => (
21        <div key={ship.name}>
22          <h3>{ship.name}</h3>
23          <p>Model: {ship.model}</p>
24        </div>
25      ))}
26    </div>
27  );
28}

Dzięki wczesnym return ostatni fragment wykona się tylko wtedy, gdy dane naprawdę istnieją, więc data.map nie wybuchnie na null. Gdybyś sprawdził pusty wynik przed ładowaniem, użytkownik przez chwilę widziałby "Brak statków", zanim dane w ogóle dotrą.

Warunkowe renderowanie stanów

Gołe napisy działają, ale każdy stan zasługuje na własny, mały komponent. Tak samo jak statek składa się z modułów, interfejs składa się z klocków, które możesz użyć ponownie w całej galaktyce aplikacji:

1function LoadingSpinner() {
2  return (
3    <div className="spinner-container">
4      <div className="spinner"></div>
5      <p>Ładowanie danych z centrum dowodzenia...</p>
6    </div>
7  );
8}
9
10function ErrorMessage({ message, onRetry }) {
11  return (
12    <div className="error-container">
13      <h3>Utracono połączenie!</h3>
14      <p>{message}</p>
15      <button onClick={onRetry}>
16        Ponów próbę
17      </button>
18    </div>
19  );
20}
21
22function EmptyState() {
23  return (
24    <div className="empty-container">
25      <p>Brak danych w tym kwadrancie galaktyki</p>
26      <p>Spróbuj przeskanować inny sektor</p>
27    </div>
28  );
29}

ErrorMessage przyjmuje prop onRetry, czyli funkcję wywoływaną po kliknięciu przycisku. Sam komponent nie wie, jak ponowić zapytanie, i dobrze: o tym decyduje rodzic.

Mechanizm ponownej próby (Retry)

Żeby przycisk "Ponów próbę" miał co wywołać, funkcja pobierająca musi być dostępna poza efektem. Przenosimy ją więc piętro wyżej, do ciała komponentu:

1function SpaceData() {
2  const [data, setData] = useState(null);
3  const [loading, setLoading] = useState(true);
4  const [error, setError] = useState(null);
5
6  const fetchData = async () => {
7    try {
8      setLoading(true);
9      setError(null);
10      const response = await fetch(
11        'https://swapi.dev/api/people/'
12      );
13      if (!response.ok) throw new Error('Błąd sieci');
14      const result = await response.json();
15      setData(result.results);
16    } catch (err) {
17      setError(err.message);
18    } finally {
19      setLoading(false);
20    }
21  };
22
23  useEffect(() => {
24    fetchData();
25  }, []);
26
27  if (loading) return <LoadingSpinner />;
28  if (error) return <ErrorMessage message={error} onRetry={fetchData} />;
29  if (!data || data.length === 0) return <EmptyState />;
30
31  return (
32    <ul>
33      {data.map(person => (
34        <li key={person.name}>{person.name}</li>
35      ))}
36    </ul>
37  );
38}

Kluczowa różnica: funkcja fetchData jest zdefiniowana poza useEffect, dzięki czemu możemy ją wywołać ponownie z przycisku "Ponów próbę". Logika pobierania się nie zmieniła, zmieniło się tylko jej miejsce. Wtyczka ESLint react-hooks może tu zgłosić brak fetchData w tablicy zależności. Dopóki funkcja nie korzysta z propsów ani stanu, który się zmienia, efekt i tak działa poprawnie.

Szkieletowe ładowanie (Skeleton Loading)

Spinner mówi "czekaj", ale nie mówi, na co. Szkielet interfejsu, czyli szare prostokąty w miejscu przyszłych kart, pokazuje kształt treści i sprawia, że strona mniej "skacze", gdy dane dotrą:

1function SkeletonCard() {
2  return (
3    <div className="skeleton-card">
4      <div className="skeleton-title"></div>
5      <div className="skeleton-text"></div>
6      <div className="skeleton-text short"></div>
7    </div>
8  );
9}
10
11function PlanetCatalog() {
12  const [planets, setPlanets] = useState(null);
13  const [loading, setLoading] = useState(true);
14
15  // ... fetch logic ...
16
17  if (loading) {
18    return (
19      <div>
20        <SkeletonCard />
21        <SkeletonCard />
22        <SkeletonCard />
23      </div>
24    );
25  }
26
27  return (
28    <div>
29      {planets.map(planet => (
30        <PlanetCard key={planet.name} planet={planet} />
31      ))}
32    </div>
33  );
34}

Logika pobierania jest identyczna jak wcześniej (dlatego skrócona do komentarza), zmienia się tylko widok stanu ładowania. Szkielety polecam przy listach i kartach, a spinner zostaw dla krótkich akcji, jak wysłanie formularza.

Dobra obsługa stanów to różnica między profesjonalną aplikacją a amatorskim projektem. W kolejnej lekcji zobaczysz, co się dzieje, gdy kilka zapytań ściga się ze sobą, i jak AbortController chroni te same trzy stany przed nieaktualnymi danymi.

Pamiętaj: pilot zawsze widzi status systemów na panelu, więc Twój użytkownik też zawsze powinien wiedzieć, czy czeka, czy coś się zepsuło, czy po prostu nic nie znaleziono.

Kod do tej lekcji: App.jsx
1import React, { useState, useEffect } from 'react';
2
3// Komponenty stanow
4function LoadingSpinner() {
5  return (
6    <div className="state-container loading">
7      <div className="spinner"></div>
8      <p>Skanowanie sektora kosmicznego...</p>
9    </div>
10  );
11}
12
13function ErrorMessage({ message, onRetry }) {
14  return (
15    <div className="state-container error">
16      <h3>Utracono polaczenie z centrum dowodzenia!</h3>
17      <p>{message}</p>
18      <button className="retry-btn" onClick={onRetry}>
19        Ponow skanowanie
20      </button>
21    </div>
22  );
23}
24
25function EmptyState() {
26  return (
27    <div className="state-container empty">
28      <h3>Sektor pusty</h3>
29      <p>Nie wykryto zadnych statkow w tym kwadrancie</p>
30    </div>
31  );
32}
33
34function StarshipCatalog() {
35  const [data, setData] = useState(null);
36  const [loading, setLoading] = useState(true);
37  const [error, setError] = useState(null);
38
39  const fetchStarships = async () => {
40    try {
41      setLoading(true);
42      setError(null);
43
44      const response = await fetch('https://swapi.dev/api/starships/');
45
46      if (!response.ok) {
47        throw new Error(`HTTP Error: ${response.status}`);
48      }
49
50      const result = await response.json();
51      setData(result.results);
52    } catch (err) {
53      setError(err.message);
54    } finally {
55      setLoading(false);
56    }
57  };
58
59  useEffect(() => {
60    fetchStarships();
61  }, []);
62
63  if (loading) return <LoadingSpinner />;
64  if (error) return <ErrorMessage message={error} onRetry={fetchStarships} />;
65  if (!data || data.length === 0) return <EmptyState />;
66
67  return (
68    <div className="catalog">
69      <h1>Katalog Statkow Kosmicznych</h1>
70      <p>Wykryto {data.length} statkow</p>
71      <div className="ship-grid">
72        {data.map(ship => (
73          <div key={ship.name} className="ship-card">
74            <h3>{ship.name}</h3>
75            <p><strong>Model:</strong> {ship.model}</p>
76            <p><strong>Producent:</strong> {ship.manufacturer}</p>
77            <p><strong>Predkosc:</strong> {ship.max_atmosphering_speed}</p>
78            <p><strong>Zaloga:</strong> {ship.crew}</p>
79          </div>
80        ))}
81      </div>
82    </div>
83  );
84}
85
86export default StarshipCatalog;

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. W jakiej kolejności powinny być sprawdzane stany w komponencie pobierającym dane?

  2. 2. Ile zmiennych stanu (useState) potrzebujemy w standardowym wzorcu obsługi data fetchingu w React?

Zadania praktyczne w grze

  • Edytor kodu

    Loading, error i empty states

  • Układanie w poziomie

    Ułóż składnię async/await do pobrania danych JSON z API:

  • Edytor kodu

    Retry po błędzie

Przydatne artykuły