Kurs JavaScript i React · Moduł 16: Obsługa błędów i Suspense
Fallback UI i Skeleton Loaders
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
- Skeleton powinien odwzorowywać układ treści - taki sam rozmiar i kształt
- Animacja pulsu - daje poczucie, że coś się dzieje
- Unikaj migotania - jeśli ładowanie trwa <300ms, nie pokazuj skeletonów
- Użyj różnych skeletonów dla różnych typów treści
- 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. 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: