Kurs JavaScript i React · Moduł 8: Zaawansowane wzorce React

Concurrent Mode i Suspense

5 min czytania
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?

APIZastosowaniePrzykład
startTransitionAktualizacje, które mogą poczekaćZmiana filtrów, nawigacja między zakładkami
useTransitionJak wyżej + wskaźnik ładowaniaPrzełączanie widoków z loading state
SuspenseOczekiwanie na dane lub kodLazy loading, pobieranie danych
useDeferredValueOpóźnienie renderowania wartościFiltrowanie 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. 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

Przydatne artykuły