Kurs JavaScript i React · Moduł 16: Obsługa błędów i Suspense

Fallback UI i Skeleton Loaders

5 min czytania
W tej lekcji5

Wyobraź sobie że otwierasz panel nawigacyjny, a przez dwie sekundy widzisz pusty, czarny ekran. Czy system się zawiesił? Czy statek w ogóle odpowiada? Po chwili nagle wyskakuje cały panel i przesuwa przyciski, w które właśnie celowałeś. To nie jest awaria, a jednak załoga traci zaufanie do stacji. W kosmosie, gdy moduł stacji jest w trakcie inicjalizacji, załoga widzi wskaźniki postępu i holograficzne kontury przyszłego interfejsu. W React mamy skeleton loadery i fallback UI - elementy zastępczego interfejsu, które informują użytkownika o trwającym ładowaniu.

Dlaczego Fallback UI jest ważny?

Dobre ładowanie to nie tylko spinner. Użytkownik powinien mieć poczucie, że coś się dzieje, i wiedzieć, czego się spodziewać. Fallback, który zajmuje tyle miejsca co docelowa treść, zapobiega też skakaniu układu, gdy dane w końcu dotrą.

Poziomy jakości ładowania (od najgorszego do najlepszego)

Porównaj cztery wersje tego samego Suspense. Zmienia się wyłącznie prop fallback, czyli to, co React pokaże, dopóki komponent w środku nie będzie gotowy:

1// 1. Nic (najgorsze) - użytkownik nie wie co się dzieje
2<Suspense fallback={null}>
3
4// 2. Prosty tekst
5<Suspense fallback={<p>Ładowanie...</p>}>
6
7// 3. Spinner/Loader
8<Suspense fallback={<Spinner />}>
9
10// 4. Skeleton loader (najlepsze)
11<Suspense fallback={<ContentSkeleton />}>

Mechanika ładowania jest w każdym wariancie identyczna, różni się tylko komunikat dla załogi. Dla porządku: Suspense to wbudowany komponent React, który pokazuje fallback, dopóki coś w jego wnętrzu "czeka". Według react.dev reaguje na komponenty ładowane leniwie przez React.lazy() i na obietnice odczytywane hookiem use, a nie na dowolny fetch w efekcie.

Skeleton Loaders

Skeleton loader to zastępczy UI, który naśladuje kształt i układ docelowej treści. Użytkownik widzi "szkielet" strony, który stopniowo wypełnia się prawdziwą treścią. To jak hologram modułu wyświetlany, zanim moduł fizycznie zadokuje do stacji.

Podstawowy Skeleton w CSS

Szkielet karty składa się ze zwykłych, pustych elementów div. Każdy dostaje klasę opisującą jego kształt i wspólną klasę animacji skeleton-pulse:

1function CardSkeleton() {
2  return (
3    <div className="skeleton-card">
4      <div className="skeleton-image skeleton-pulse" />
5      <div className="skeleton-title skeleton-pulse" />
6      <div className="skeleton-text skeleton-pulse" />
7      <div className="skeleton-text skeleton-pulse short" />
8    </div>
9  );
10}

Komponent nie ma stanu ani propsów, bo jego jedynym zadaniem jest zająć miejsce. Cały efekt wizualny robi CSS. Gradient linear-gradient tworzy jaśniejszy pas, background-size: 200% 100% sprawia, że tło jest dwa razy szersze od elementu, a reguła @keyframes przesuwa je od prawej do lewej:

1.skeleton-pulse {
2  background: linear-gradient(
3    90deg,
4    #e0e0e0 25%,
5    #f0f0f0 50%,
6    #e0e0e0 75%
7  );
8  background-size: 200% 100%;
9  animation: pulse 1.5s infinite;
10  border-radius: 4px;
11}
12
13@keyframes pulse {
14  0% { background-position: 200% 0; }
15  100% { background-position: -200% 0; }
16}
17
18.skeleton-image {
19  width: 100%;
20  height: 200px;
21  margin-bottom: 12px;
22}
23
24.skeleton-title {
25  width: 60%;
26  height: 24px;
27  margin-bottom: 8px;
28}
29
30.skeleton-text {
31  width: 100%;
32  height: 16px;
33  margin-bottom: 6px;
34}
35
36.skeleton-text.short {
37  width: 40%;
38}

Wymiary w klasach .skeleton-image, .skeleton-title i .skeleton-text odpowiadają prawdziwej karcie, więc po załadowaniu nic nie przeskoczy. Klasa .short skraca ostatnią linię, bo prawdziwy akapit rzadko kończy się równo z krawędzią.

Skeleton dla różnych typów treści

Jeden szkielet nie pasuje do wszystkiego. Lista załogi wygląda inaczej niż pulpit ze statystykami, więc budujemy osobne kształty.

Lista elementów

Szkielet listy przyjmuje prop count, a Array.from({ length: count }) tworzy tablicę o tej długości, po której możemy iterować:

1function ListSkeleton({ count = 3 }) {
2  return (
3    <div className="list-skeleton">
4      {Array.from({ length: count }).map((_, i) => (
5        <div key={i} className="list-item-skeleton">
6          <div className="avatar-skeleton skeleton-pulse" />
7          <div className="info-skeleton">
8            <div className="skeleton-pulse name-skeleton" />
9            <div className="skeleton-pulse detail-skeleton" />
10          </div>
11        </div>
12      ))}
13    </div>
14  );
15}

Tutaj indeks jako key jest w porządku, bo elementy nigdy nie zmieniają kolejności ani nie mają własnego stanu.

Dashboard z wieloma sekcjami

Większe szkielety składasz z mniejszych, dokładnie tak jak prawdziwy pulpit składa się z kart i list:

1function DashboardSkeleton() {
2  return (
3    <div className="dashboard-skeleton">
4      <div className="stats-row">
5        <StatCardSkeleton />
6        <StatCardSkeleton />
7        <StatCardSkeleton />
8      </div>
9      <div className="main-content">
10        <ChartSkeleton />
11        <ListSkeleton count={5} />
12      </div>
13    </div>
14  );
15}

Układ siatki jest ten sam co w docelowym pulpicie, zmieniają się tylko klocki w środku.

Wzorce ładowania

Progressive Loading

Ładuj ważne treści najpierw, reszta ładuje się w tle. Każda mniej ważna sekcja dostaje własny Suspense, więc nie blokuje pozostałych:

1function MissionDashboard() {
2  return (
3    <div>
4      {/* Krytyczna treść - bez lazy loading */}
5      <MissionStatus />
6
7      {/* Mniej krytyczna - lazy loading z skeleton */}
8      <Suspense fallback={<ChartSkeleton />}>
9        <MissionChart />
10      </Suspense>
11
12      {/* Najmniej ważna - ładuje się ostatnia */}
13      <Suspense fallback={<ListSkeleton count={5} />}>
14        <MissionHistory />
15      </Suspense>
16    </div>
17  );
18}

Zakładamy tu, że MissionChart i MissionHistory są komponentami z React.lazy(). MissionStatus jest importowany normalnie, więc pojawia się od razu i nie ma własnego fallbacku.

Inline Loading States

Nie zawsze potrzebujesz Suspense. Dla danych ładowanych asynchronicznie w efekcie używaj stanów ładowania, bo Suspense nie wie o fetch wywołanym w useEffect:

1function CrewMember({ id }) {
2  const [member, setMember] = useState(null);
3  const [loading, setLoading] = useState(true);
4
5  useEffect(() => {
6    setLoading(true); // Nowe id - znowu pokazujemy skeleton
7    fetchCrewMember(id)
8      .then(setMember)
9      .finally(() => setLoading(false));
10  }, [id]);
11
12  if (loading) return <MemberSkeleton />;
13
14  return (
15    <div className="crew-member">
16      <img src={member.avatar} alt={member.name} />
17      <h3>{member.name}</h3>
18      <p>{member.role}</p>
19    </div>
20  );
21}

Linia setLoading(true) na początku efektu sprawia, że po zmianie id szkielet wraca, zamiast przez chwilę pokazywać poprzedniego członka załogi. finally wyłącza ładowanie zarówno po sukcesie, jak i po błędzie. Samą obsługę błędu dodasz w następnej lekcji.

Dobre praktyki

  1. Skeleton powinien odwzorowywać układ treści - taki sam rozmiar i kształt
  2. Animacja pulsu - daje poczucie, że coś się dzieje
  3. Unikaj migotania - jeśli ładowanie trwa <300ms, nie pokazuj skeletonów
  4. Użyj różnych skeletonów dla różnych typów treści
  5. Zachowaj proporcje - skeleton powinien zajmować tyle samo miejsca co prawdziwa treść

Z punktem 3 React częściowo pomaga sam: według react.dev odsłania zawieszoną treść najwyżej raz na 300 ms, więc granice gotowe w tym oknie pojawiają się razem. Moja rada: zawsze zaczynaj od szkieletu, spinner zostaw na krótkie akcje w przyciskach. W następnej lekcji połączysz szkielety z granicami błędów, żeby moduł, który się nie załaduje, nie zostawił pustej dziury.

Pamiętaj: dobry szkielet to hologram modułu, który pokazuje załodze kształt tego, co za chwilę zadokuje.

Kod do tej lekcji: App.jsx
1import React, { useState, useEffect } from 'react';
2import './styles.css';
3
4// Skeleton dla karty misji
5function MissionCardSkeleton() {
6  return (
7    <div className="card skeleton-card">
8      <div className="skeleton-img skeleton-pulse" />
9      <div className="skeleton-h skeleton-pulse" />
10      <div className="skeleton-p skeleton-pulse" />
11      <div className="skeleton-p skeleton-pulse short" />
12    </div>
13  );
14}
15
16// Skeleton dla listy czlonkow zalogi
17function CrewListSkeleton({ count = 3 }) {
18  return (
19    <div className="crew-skeleton">
20      {Array.from({ length: count }).map((_, i) => (
21        <div key={i} className="crew-item-skel">
22          <div className="avatar-skel skeleton-pulse" />
23          <div className="crew-info-skel">
24            <div className="skeleton-name skeleton-pulse" />
25            <div className="skeleton-role skeleton-pulse" />
26          </div>
27        </div>
28      ))}
29    </div>
30  );
31}
32
33// Prawdziwa karta misji
34function MissionCard({ mission }) {
35  return (
36    <div className="card">
37      <div className="card-icon">{mission.icon}</div>
38      <h3>{mission.name}</h3>
39      <p>{mission.description}</p>
40      <span className={`badge ${mission.status}`}>{mission.status}</span>
41    </div>
42  );
43}
44
45// Prawdziwa lista zalogi
46function CrewList({ members }) {
47  return (
48    <div className="crew-list">
49      {members.map(m => (
50        <div key={m.id} className="crew-item">
51          <div className="avatar">{m.avatar}</div>
52          <div>
53            <strong>{m.name}</strong>
54            <p className="role">{m.role}</p>
55          </div>
56        </div>
57      ))}
58    </div>
59  );
60}
61
62export default function App() {
63  const [missions, setMissions] = useState(null);
64  const [crew, setCrew] = useState(null);
65
66  useEffect(() => {
67    // Symulacja ladowania misji (szybsze)
68    setTimeout(() => {
69      setMissions([
70        { id: 1, icon: '', name: 'Eksploracja Marsa', description: 'Badanie powierzchni', status: 'active' },
71        { id: 2, icon: '', name: 'Zwiad Jowisza', description: 'Analiza atmosfery', status: 'pending' },
72      ]);
73    }, 1200);
74
75    // Symulacja ladowania zalogi (wolniejsze)
76    setTimeout(() => {
77      setCrew([
78        { id: 1, avatar: '', name: 'Kapitan Nova', role: 'Dowodca' },
79        { id: 2, avatar: '', name: 'Dr. Stellar', role: 'Naukowiec' },
80        { id: 3, avatar: '', name: 'Tech Orion', role: 'Inzynier' },
81      ]);
82    }, 2500);
83  }, []);
84
85  return (
86    <div className="app">
87      <h1>Skeleton Loaders</h1>
88      <p className="subtitle">Obserwuj jak skeletony zamieniaja sie w tresc</p>
89
90      <section>
91        <h2>Misje</h2>
92        {missions ? (
93          <div className="cards-grid">
94            {missions.map(m => <MissionCard key={m.id} mission={m} />)}
95          </div>
96        ) : (
97          <div className="cards-grid">
98            <MissionCardSkeleton />
99            <MissionCardSkeleton />
100          </div>
101        )}
102      </section>
103
104      <section>
105        <h2>Zaloga</h2>
106        {crew ? <CrewList members={crew} /> : <CrewListSkeleton count={3} />}
107      </section>
108    </div>
109  );
110}

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. Dlaczego skeleton loader jest lepszy od prostego spinnera?

Zadania praktyczne w grze

  • Edytor kodu

    Zaimplementuj MissionCardSkeleton

  • Układanie w pionie

    Uporządkuj poziomy jakości ładowania od najgorszego do najlepszego:

  • Klikanie w kolejności

    Kliknij elementy w kolejności budowania animacji skeleton pulse:

Przydatne artykuły