Kurs JavaScript i React · Moduł 8: Zaawansowane wzorce React
Concurrent Mode i Suspense
W tej lekcji9
Wyobraź sobie centrum kontroli misji na stacji kosmicznej, gdzie jednocześnie przetwarzane są setki sygnałów z różnych czujników. Niektóre dane są pilne (alarmy), inne mniej (statystyki pogodowe z odległych planet). React Concurrent Mode działa na podobnej zasadzie - pozwala priorytetyzować aktualizacje interfejsu, aby aplikacja pozostała responsywna nawet podczas ciężkich operacji.
Czym jest Concurrent Mode?
Concurrent Mode (tryb współbieżny) to zestaw mechanizmów w React, które pozwalają na przerywanie i wznawianie renderowania. Tradycyjnie React renderował synchronicznie - gdy zaczynał aktualizację, musiał ją dokończyć, zanim mógł zareagować na cokolwiek innego. W trybie współbieżnym React może:
- Rozpocząć renderowanie aktualizacji
- Przerwać je, gdy pojawi się pilniejsza aktualizacja
- Wrócić do przerwanej pracy później
To jak kosmiczny kontroler, który może odłożyć analizę danych z dalekiej sondy, gdy nagle pojawi się alert o zbliżającej się asteroidzie.
startTransition - priorytetyzacja aktualizacji
startTransition to API, które mówi Reactowi: "ta aktualizacja nie jest pilna, możesz ją przerwać". Jest idealne dla operacji, które mogą chwilę poczekać, jak filtrowanie dużej listy czy nawigacja między zakładkami.
1import { useState, startTransition } from 'react';
2
3function GalaxySearch() {
4 const [searchInput, setSearchInput] = useState('');
5 const [searchResults, setSearchResults] = useState([]);
6
7 const handleSearch = (value) => {
8 // Aktualizacja inputa jest PILNA - użytkownik musi widzieć co pisze
9 setSearchInput(value);
10
11 // Filtrowanie wyników jest MNIEJ PILNE
12 startTransition(() => {
13 const filtered = galaxyDatabase.filter(
14 galaxy => galaxy.name.toLowerCase().includes(value.toLowerCase())
15 );
16 setSearchResults(filtered);
17 });
18 };
19
20 return (
21 <div>
22 <input
23 value={searchInput}
24 onChange={(e) => handleSearch(e.target.value)}
25 placeholder="Szukaj galaktyk..."
26 />
27 <GalaxyList results={searchResults} />
28 </div>
29 );
30}Dzięki startTransition input reaguje natychmiast na wpisywanie, nawet jeśli filtrowanie tysięcy galaktyk zajmuje chwilę.
useTransition - śledzenie stanu przejścia
useTransition to hook, który oprócz startTransition daje nam flagę isPending, informującą, czy przejście jest w toku:
1import { useState, useTransition } from 'react';
2
3function MissionDashboard() {
4 const [activeTab, setActiveTab] = useState('overview');
5 const [isPending, startTransition] = useTransition();
6
7 const switchTab = (tab) => {
8 startTransition(() => {
9 setActiveTab(tab);
10 });
11 };
12
13 return (
14 <div>
15 <nav>
16 {['overview', 'crew', 'systems', 'navigation'].map(tab => (
17 <button
18 key={tab}
19 onClick={() => switchTab(tab)}
20 style={{
21 opacity: isPending && activeTab !== tab ? 0.7 : 1,
22 fontWeight: activeTab === tab ? 'bold' : 'normal',
23 }}
24 >
25 {tab.toUpperCase()}
26 </button>
27 ))}
28 </nav>
29
30 {isPending && <div className="loading-bar">Ładowanie panelu...</div>}
31
32 <TabContent tab={activeTab} />
33 </div>
34 );
35}Suspense - deklaratywne oczekiwanie na dane
Suspense to komponent, który pozwala "zawiesić" renderowanie, dopóki dane nie będą gotowe. Zamiast ręcznie zarządzać stanami ładowania, deklarujesz co pokazać podczas oczekiwania:
1import { Suspense } from 'react';
2
3function SpaceStation() {
4 return (
5 <div>
6 <h1>Stacja Kosmiczna Alpha</h1>
7
8 <Suspense fallback={<LoadingSpinner message="Ładowanie danych załogi..." />}>
9 <CrewList />
10 </Suspense>
11
12 <Suspense fallback={<LoadingSpinner message="Sprawdzanie systemów..." />}>
13 <SystemsStatus />
14 </Suspense>
15 </div>
16 );
17}Każda sekcja ładuje się niezależnie - użytkownik widzi dane załogi, gdy tylko będą gotowe, nie czekając na status systemów.
Suspense z lazy loading
Najczęstsze zastosowanie Suspense to ładowanie komponentów dynamicznie za pomocą React.lazy:
1import { lazy, Suspense } from 'react';
2
3// Komponenty ładowane dynamicznie
4const StarMap = lazy(() => import('./StarMap'));
5const MissionLog = lazy(() => import('./MissionLog'));
6const CrewManagement = lazy(() => import('./CrewManagement'));
7
8function CommandCenter() {
9 const [view, setView] = useState('map');
10
11 return (
12 <div>
13 <nav>
14 <button onClick={() => setView('map')}>Mapa gwiazd</button>
15 <button onClick={() => setView('log')}>Dziennik misji</button>
16 <button onClick={() => setView('crew')}>Załoga</button>
17 </nav>
18
19 <Suspense fallback={<div>Ładowanie modułu...</div>}>
20 {view === 'map' && <StarMap />}
21 {view === 'log' && <MissionLog />}
22 {view === 'crew' && <CrewManagement />}
23 </Suspense>
24 </div>
25 );
26}Zagnieżdżone Suspense
Możemy zagnieżdżać komponenty Suspense, tworząc hierarchię ładowania. Zewnętrzny Suspense łapie wszystko, wewnętrzne pozwalają na bardziej granularną kontrolę:
1function MissionControl() {
2 return (
3 <Suspense fallback={<FullPageLoader />}>
4 <header>
5 <MissionTitle />
6 </header>
7
8 <main>
9 <Suspense fallback={<SectionLoader text="Mapa..." />}>
10 <InteractiveStarMap />
11 </Suspense>
12
13 <aside>
14 <Suspense fallback={<SectionLoader text="Statystyki..." />}>
15 <MissionStats />
16 </Suspense>
17
18 <Suspense fallback={<SectionLoader text="Komunikaty..." />}>
19 <CommunicationLog />
20 </Suspense>
21 </aside>
22 </main>
23 </Suspense>
24 );
25}useDeferredValue - opóźnione wartości
useDeferredValue to hook, który pozwala opóźnić aktualizację wartości. React najpierw wyrenderuje z poprzednią wartością (pilna aktualizacja), a potem z nową (mniej pilna):
1import { useState, useDeferredValue, useMemo } from 'react';
2
3function PlanetExplorer() {
4 const [filter, setFilter] = useState('');
5 const deferredFilter = useDeferredValue(filter);
6
7 // Lista renderuje się z opóźnioną wartością filtra
8 const filteredPlanets = useMemo(() => {
9 return allPlanets.filter(p =>
10 p.name.toLowerCase().includes(deferredFilter.toLowerCase())
11 );
12 }, [deferredFilter]);
13
14 const isStale = filter !== deferredFilter;
15
16 return (
17 <div>
18 <input
19 value={filter}
20 onChange={(e) => setFilter(e.target.value)}
21 placeholder="Filtruj planety..."
22 />
23 <div style={{ opacity: isStale ? 0.6 : 1 }}>
24 {filteredPlanets.map(planet => (
25 <PlanetCard key={planet.id} planet={planet} />
26 ))}
27 </div>
28 </div>
29 );
30}Kiedy używać poszczególnych API?
| API | Zastosowanie | Przykład |
|---|---|---|
startTransition | Aktualizacje, które mogą poczekać | Zmiana filtrów, nawigacja między zakładkami |
useTransition | Jak wyżej + wskaźnik ładowania | Przełączanie widoków z loading state |
Suspense | Oczekiwanie na dane lub kod | Lazy loading, pobieranie danych |
useDeferredValue | Opóźnienie renderowania wartości | Filtrowanie dużych list, wyszukiwanie |
Podsumowanie
Concurrent Mode i Suspense to jak zaawansowany system zarządzania priorytetami na stacji kosmicznej. Zamiast przetwarzać wszystkie sygnały sekwencyjnie (blokując ważne alerty na czas analizy mniej istotnych danych), React może inteligentnie priorytetyzować aktualizacje:
- startTransition pozwala oznaczyć mniej pilne aktualizacje
- useTransition dodatkowo informuje o stanie przejścia
- Suspense deklaratywnie zarządza oczekiwaniem na dane
- useDeferredValue opóźnia kosztowne renderowania
Te mechanizmy współpracują ze sobą, tworząc responsywne interfejsy nawet w złożonych aplikacjach z dużą ilością danych i ciężkimi operacjami.
Kod do tej lekcji: App.jsx
1import React, { useState, useTransition, useDeferredValue, Suspense, lazy, useMemo } from 'react';
2
3// ============================================
4// DEMO: Concurrent Mode i Suspense
5// ============================================
6
7// Symulacja wolnego API
8function fetchGalaxies(filter) {
9 return new Promise(resolve => {
10 setTimeout(() => {
11 const allGalaxies = [
12 'Droga Mleczna', 'Andromeda', 'Trójkąt', 'Sombrero',
13 'Wir', 'Czarne Oko', 'Centaurus A', 'Cygnus A',
14 'Wielki Obłok Magellana', 'Mały Obłok Magellana',
15 'NGC 1300', 'NGC 4414', 'Messier 87', 'Pinwheel',
16 'Cartwheel', 'Hoag', 'Sunflower', 'Tadpole',
17 ];
18 const filtered = allGalaxies.filter(g =>
19 g.toLowerCase().includes(filter.toLowerCase())
20 );
21 resolve(filtered);
22 }, 800);
23 });
24}
25
26// --- SEKCJA 1: startTransition i useTransition ---
27function GalaxySearch() {
28 const [searchInput, setSearchInput] = useState('');
29 const [searchResults, setSearchResults] = useState([]);
30 const [isPending, startTransition] = useTransition();
31
32 const handleSearch = async (value) => {
33 setSearchInput(value);
34
35 startTransition(async () => {
36 const results = await fetchGalaxies(value);
37 setSearchResults(results);
38 });
39 };
40
41 return (
42 <section style={sectionStyle}>
43 <h2 style={headingStyle}>useTransition - Wyszukiwanie galaktyk</h2>
44 <p style={{ fontSize: '13px', opacity: 0.7, marginBottom: '12px' }}>
45 Wpisz nazwę galaktyki. Input reaguje natychmiast, lista aktualizuje się z opóźnieniem.
46 </p>
47 <input
48 value={searchInput}
49 onChange={(e) => handleSearch(e.target.value)}
50 placeholder="Szukaj galaktyk..."
51 style={inputStyle}
52 />
53 <div style={{ opacity: isPending ? 0.5 : 1, transition: 'opacity 0.3s' }}>
54 {isPending && <p style={{ color: '#ffd700' }}>Przeszukiwanie kosmosu...</p>}
55 {searchResults.length > 0 ? (
56 <ul style={{ listStyle: 'none', padding: 0 }}>
57 {searchResults.map((galaxy, i) => (
58 <li key={i} style={listItemStyle}>{galaxy}</li>
59 ))}
60 </ul>
61 ) : (
62 !isPending && searchInput && <p style={{ opacity: 0.5 }}>Brak wyników</p>
63 )}
64 </div>
65 </section>
66 );
67}
68
69// --- SEKCJA 2: useDeferredValue ---
70function HeavyPlanetList({ filter }) {
71 const planets = useMemo(() => {
72 const allPlanets = [];
73 for (let i = 1; i <= 200; i++) {
74 allPlanets.push(`Planeta-${String(i).padStart(3, '0')}`);
75 }
76 return allPlanets.filter(p =>
77 p.toLowerCase().includes(filter.toLowerCase())
78 );
79 }, [filter]);
80
81 return (
82 <ul style={{ listStyle: 'none', padding: 0, maxHeight: '200px', overflowY: 'auto' }}>
83 {planets.map((planet, i) => (
84 <li key={i} style={listItemStyle}>{planet}</li>
85 ))}
86 </ul>
87 );
88}
89
90function DeferredValueDemo() {
91 const [filter, setFilter] = useState('');
92 const deferredFilter = useDeferredValue(filter);
93 const isStale = filter !== deferredFilter;
94
95 return (
96 <section style={sectionStyle}>
97 <h2 style={headingStyle}>useDeferredValue - Filtrowanie planet</h2>
98 <p style={{ fontSize: '13px', opacity: 0.7, marginBottom: '12px' }}>
99 Lista renderuje się z opóźnioną wartością filtra.
100 </p>
101 <input
102 value={filter}
103 onChange={(e) => setFilter(e.target.value)}
104 placeholder="Filtruj planety..."
105 style={inputStyle}
106 />
107 <div style={{ opacity: isStale ? 0.5 : 1, transition: 'opacity 0.3s' }}>
108 <HeavyPlanetList filter={deferredFilter} />
109 </div>
110 </section>
111 );
112}
113
114// --- SEKCJA 3: Suspense z lazy loading ---
115const StarMap = lazy(() => new Promise(resolve => {
116 setTimeout(() => {
117 resolve({
118 default: () => (
119 <div style={{ padding: '20px', background: '#0d1b2a', borderRadius: '8px' }}>
120 <h3 style={{ color: '#00d4ff' }}>Mapa gwiazd</h3>
121 <p>Wyświetlanie 1,247 systemów gwiezdnych w sektorze Alpha...</p>
122 <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '8px', marginTop: '12px' }}>
123 {['Sirius', 'Vega', 'Altair', 'Rigel', 'Betelgeuse', 'Polaris', 'Deneb', 'Capella'].map(star => (
124 <div key={star} style={{ padding: '8px', background: '#1a2a3a', borderRadius: '4px', textAlign: 'center', fontSize: '12px' }}>
125 {star}
126 </div>
127 ))}
128 </div>
129 </div>
130 )
131 });
132 }, 1500);
133}));
134
135const MissionLog = lazy(() => new Promise(resolve => {
136 setTimeout(() => {
137 resolve({
138 default: () => (
139 <div style={{ padding: '20px', background: '#0d1b2a', borderRadius: '8px' }}>
140 <h3 style={{ color: '#ffd700' }}>Dziennik misji</h3>
141 {['Misja Alpha rozpoczęta', 'Osiągnięto orbitę Marsa', 'Sonda wysłana na Europę', 'Kontakt z bazą utracony', 'Systemy przywrócone'].map((log, i) => (
142 <p key={i} style={{ fontSize: '13px', padding: '4px 0', borderBottom: '1px solid #1a2a3a' }}>
143 [{5 - i}h temu] {log}
144 </p>
145 ))}
146 </div>
147 )
148 });
149 }, 2000);
150}));
151
152function SuspenseDemo() {
153 const [view, setView] = useState('none');
154
155 return (
156 <section style={sectionStyle}>
157 <h2 style={headingStyle}>Suspense - Lazy loading</h2>
158 <p style={{ fontSize: '13px', opacity: 0.7, marginBottom: '12px' }}>
159 Komponenty ładowane dynamicznie z Suspense fallback.
160 </p>
161 <div style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
162 <button onClick={() => setView('map')} style={view === 'map' ? btnActiveStyle : btnStyle}>
163 Mapa gwiazd
164 </button>
165 <button onClick={() => setView('log')} style={view === 'log' ? btnActiveStyle : btnStyle}>
166 Dziennik misji
167 </button>
168 </div>
169 <Suspense fallback={
170 <div style={{ padding: '20px', textAlign: 'center', color: '#ffd700' }}>
171 Ładowanie modułu...
172 </div>
173 }>
174 {view === 'map' && <StarMap />}
175 {view === 'log' && <MissionLog />}
176 </Suspense>
177 </section>
178 );
179}
180
181// --- SEKCJA 4: Tabs z useTransition ---
182function TabsDemo() {
183 const [activeTab, setActiveTab] = useState('overview');
184 const [isPending, startTransition] = useTransition();
185
186 const tabs = [
187 { id: 'overview', label: 'Przegląd', content: 'Status misji: Aktywna. Wszystkie systemy nominalne.' },
188 { id: 'crew', label: 'Załoga', content: 'Załoga: 6 członków. Kapitan Ra, 2 pilotów, 2 inżynierów, 1 naukowiec.' },
189 { id: 'systems', label: 'Systemy', content: 'Silniki: 85%. Tlen: 97%. Tarcze: 100%. Komunikacja: Aktywna.' },
190 ];
191
192 const switchTab = (tabId) => {
193 startTransition(() => {
194 setActiveTab(tabId);
195 });
196 };
197
198 const activeContent = tabs.find(t => t.id === activeTab);
199
200 return (
201 <section style={sectionStyle}>
202 <h2 style={headingStyle}>useTransition - Zakładki</h2>
203 <div style={{ display: 'flex', gap: '4px', marginBottom: '12px' }}>
204 {tabs.map(tab => (
205 <button
206 key={tab.id}
207 onClick={() => switchTab(tab.id)}
208 style={{
209 ...btnStyle,
210 background: activeTab === tab.id ? '#00d4ff' : '#1a2a3a',
211 color: activeTab === tab.id ? '#000' : '#fff',
212 opacity: isPending ? 0.7 : 1,
213 }}
214 >
215 {tab.label}
216 </button>
217 ))}
218 </div>
219 <div style={{ padding: '12px', background: '#0d1b2a', borderRadius: '6px' }}>
220 {isPending ? (
221 <p style={{ color: '#ffd700' }}>Przełączanie...</p>
222 ) : (
223 <p>{activeContent?.content}</p>
224 )}
225 </div>
226 </section>
227 );
228}
229
230// --- Style ---
231const sectionStyle = {
232 marginBottom: '24px',
233 padding: '16px',
234 background: 'rgba(255,255,255,0.03)',
235 borderRadius: '12px',
236 border: '1px solid rgba(255,255,255,0.08)',
237};
238const headingStyle = { color: '#00d4ff', marginBottom: '8px', fontSize: '16px' };
239const inputStyle = {
240 width: '100%',
241 padding: '10px',
242 background: '#0d1b2a',
243 border: '1px solid #1a3a5c',
244 borderRadius: '6px',
245 color: '#fff',
246 fontSize: '14px',
247 marginBottom: '12px',
248 boxSizing: 'border-box',
249};
250const listItemStyle = {
251 padding: '6px 10px',
252 background: '#0d1b2a',
253 marginBottom: '4px',
254 borderRadius: '4px',
255 fontSize: '13px',
256};
257const btnStyle = {
258 padding: '8px 14px',
259 background: '#1a2a3a',
260 border: 'none',
261 borderRadius: '6px',
262 color: '#fff',
263 cursor: 'pointer',
264 fontSize: '13px',
265};
266const btnActiveStyle = {
267 ...btnStyle,
268 background: '#00d4ff',
269 color: '#000',
270};
271
272// --- App ---
273function App() {
274 return (
275 <div style={{ padding: '20px', background: '#0a0e1a', color: '#e0e0e0', minHeight: '100vh', fontFamily: 'system-ui' }}>
276 <h1 style={{ color: '#ffd700', textAlign: 'center', marginBottom: '24px' }}>
277 Concurrent Mode i Suspense
278 </h1>
279 <GalaxySearch />
280 <DeferredValueDemo />
281 <SuspenseDemo />
282 <TabsDemo />
283 </div>
284 );
285}
286
287export default App;Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Kiedy warto użyć Higher-Order Component (HOC) zamiast custom hooka?
Zadania praktyczne w grze
- Układanie w poziomie
Ułóż składnię użycia startTransition do oznaczenia mniej pilnej aktualizacji:
- Edytor kodu
System Compound Components
- Układanie w pionie
Uporządkuj etapy tworzenia własnego custom hooka w React:
- Edytor kodu
State machine z Context API