Kurs JavaScript i React · Moduł 10: Ekosystem i przyszłość React

React 19.2 - Partial Pre-rendering (PPR)

12 min czytania
W tej lekcji9

Strona misji ma stały nagłówek i menu, ale listę aktywnych misji trzeba pobrać przy każdym żądaniu. Statyczne generowanie (SSG) poda szkielet błyskawicznie, ale ze starymi danymi, a SSR poda świeże dane, lecz użytkownik czeka na najwolniejsze zapytanie. Partial Pre-rendering (PPR) łączy oba światy na jednej stronie, jak statek z trasą obliczoną przed startem, który koryguje kurs, gdy dotrą nowe dane.

Czym jest Partial Pre-rendering?

PPR pre-renderuje statyczne części strony podczas budowania, dynamiczne renderuje w czasie żądania i strumieniuje je do klienta w miarę gotowości. React 19.2 dodał do react-dom niskopoziomowe API: prerender zwraca gotową powłokę (prelude) i odłożony stan (postponed), a resume później dokańcza renderowanie. Next.js buduje na nim wygodną warstwę. Tak wygląda podział strony:

1┌─────────────────────────────────────────┐
2│           Strona WWW                     │
3├─────────────────────────────────────────┤
4│  ┌─────────────────────────────────┐    │
5│  │    NAGŁÓWEK (pre-rendered)       │    │ ← Natychmiastowo
6│  └─────────────────────────────────┘    │
7│  ┌─────────────────────────────────┐    │
8│  │    SIDEBAR (pre-rendered)        │    │ ← Natychmiastowo
9│  └─────────────────────────────────┘    │
10│  ┌─────────────────────────────────┐    │
11│  │    CONTENT (Suspense boundary)   │    │ ← Streaming
12│  │    ┌───────────────────────┐     │    │
13│  │    │  Loading...           │     │    │
14│  │    │  → Dynamic content    │     │    │
15│  │    └───────────────────────┘     │    │
16│  └─────────────────────────────────┘    │
17└─────────────────────────────────────────┘

Nagłówek i sidebar trafiają do przeglądarki od razu, a fallback w miejscu treści po chwili zamienia się w dane. Granicę wyznacza <Suspense>.

PPR w samym Reakcie: prerender i resume

Next.js nie wymyśla tu własnego mechanizmu, tylko korzysta z API dodanego w React 19.2. Na etapie budowania prerender z react-dom/static renderuje aplikację, a gdy przerwiesz go sygnałem z AbortController, granice Suspense z niegotowymi danymi trafiają do powłoki jako fallback, a ich stan ląduje w obiekcie postponed. Przy żądaniu resume z react-dom/server dokańcza renderowanie właśnie od tego miejsca:

1// build.js - etap budowania
2import { prerender } from 'react-dom/static';
3
4const controller = new AbortController();
5setTimeout(() => controller.abort(), 100); // Po chwili przestań czekać na dane z żądania
6
7const { prelude, postponed } = await prerender(<MissionsPage />, {
8  signal: controller.signal,
9});
10await saveShell(prelude);            // Statyczna powłoka HTML, np. do CDN
11await savePostponedState(postponed); // Stan odłożonych granic Suspense
12
13// server.js - przy każdym żądaniu
14import { resume } from 'react-dom/server';
15
16const postponedState = await loadPostponedState();
17const stream = await resume(<MissionsPage />, postponedState);
18// Przeglądarka dostaje najpierw powłokę z CDN, a potem ten strumień z resztą HTML

prelude to gotowy HTML powłoki, a postponed to zwykły obiekt JSON, który zapiszesz i odczytasz przy żądaniu. Jeśli wszystko zdążyło się wyrenderować, postponed ma wartość null. Do generowania stron statycznych służy jeszcze resumeAndPrerender z react-dom/static, a w Node.js warianty dla strumieni Node: resumeToPipeableStream i prerenderToNodeStream.

Jak działa PPR?

Konfiguracja w Next.js

W Next.js 15 PPR było eksperymentalne i włączało się flagą experimental.ppr. Next.js 16 ją usunął, a PPR jest domyślnym zachowaniem po włączeniu Cache Components:

1// next.config.js (Next.js 16+)
2module.exports = {
3  cacheComponents: true, // Włącza Cache Components, a z nimi Partial Pre-rendering
4};

Z Cache Components dane są domyślnie dynamiczne, a do powłoki trafia to, co nie czyta danych z żądania albo jest oznaczone dyrektywą 'use cache'. Dane z żądania to cookies(), headers(), searchParams, params bez generateStaticParams, zapytania bez cache i wywołanie connection(). Komponent, który ich używa, musi leżeć wewnątrz <Suspense>, inaczej Next.js zgłosi błąd prerenderingu. Cache Components wymaga środowiska Node.js, więc trasy z przestarzałym eksportem runtime = 'edge' trzeba przenieść.

Struktura strony z PPR

Strona z PPR to zwykły komponent, w którym część dynamiczną owijasz w Suspense:

1// app/missions/page.jsx
2import { Suspense } from 'react';
3import MissionHeader from './MissionHeader';
4import MissionSidebar from './MissionSidebar';
5import MissionList from './MissionList';
6import MissionListSkeleton from './MissionListSkeleton';
7
8// Ta strona będzie częściowo pre-renderowana
9export default function MissionsPage() {
10  return (
11    <div className="missions-layout">
12      {/* Te komponenty są statyczne - pre-rendered */}
13      <MissionHeader />
14      <MissionSidebar />
15
16      {/* Ten komponent jest dynamiczny - streaming */}
17      <Suspense fallback={<MissionListSkeleton />}>
18        <MissionList />
19      </Suspense>
20    </div>
21  );
22}

MissionHeader i MissionSidebar trafią do statycznej powłoki, a MissionList wyrenderuje się przy żądaniu. Sposób pisania komponentów się nie zmienia.

Komponenty statyczne (pre-rendered)

Komponenty statyczne nie pobierają danych ani nie czytają niczego z żądania, więc serwer renderuje je raz, podczas budowania:

1// MissionHeader.jsx - statyczny komponent
2export default function MissionHeader() {
3  return (
4    <header className="mission-header">
5      <h1>Centrum Kontroli Misji</h1>
6      <nav>
7        <a href="/missions">Wszystkie misje</a>
8        <a href="/missions/active">Aktywne</a>
9        <a href="/missions/completed">Ukończone</a>
10      </nav>
11    </header>
12  );
13}
14
15// MissionSidebar.jsx - statyczny komponent
16export default function MissionSidebar() {
17  return (
18    <aside className="mission-sidebar">
19      <h2>Filtruj misje</h2>
20      <ul>
21        <li>Misje eksploracyjne</li>
22        <li>Misje badawcze</li>
23        <li>Misje ratunkowe</li>
24      </ul>
25    </aside>
26  );
27}

Ich HTML jest taki sam dla wszystkich, więc może leżeć w CDN.

Komponenty dynamiczne (streaming)

Komponent dynamiczny to asynchroniczny Server Component, który pobiera świeże dane przy każdym żądaniu:

1// MissionList.jsx - dynamiczny Server Component
2async function getMissions() {
3  // Bez 'use cache' fetch niczego nie zapamiętuje: dane pobierane przy każdym żądaniu
4  const res = await fetch('https://api.space-missions.com/missions');
5  return res.json();
6}
7
8export default async function MissionList() {
9  const missions = await getMissions();
10
11  return (
12    <div className="mission-list">
13      {missions.map(mission => (
14        <MissionCard key={mission.id} mission={mission} />
15      ))}
16    </div>
17  );
18}
19
20// MissionListSkeleton.jsx - fallback podczas ładowania
21export default function MissionListSkeleton() {
22  return (
23    <div className="mission-list skeleton">
24      {[1, 2, 3].map(i => (
25        <div key={i} className="mission-card-skeleton">
26          <div className="skeleton-title" />
27          <div className="skeleton-text" />
28          <div className="skeleton-text short" />
29        </div>
30      ))}
31    </div>
32  );
33}

Z Cache Components fetch bez dyrektywy 'use cache' niczego nie zapamiętuje, więc lista pobiera się przy każdym żądaniu, a do nadejścia danych widać MissionListSkeleton. Szkielet powinien mieć wymiary listy, żeby treść nie skakała.

Zalety PPR

Szybszy Time To First Byte (TTFB)

Porównaj oś czasu żądania bez PPR i z PPR:

1Bez PPR:
2Request → Render całości → Response
3         [==========] 800ms
4
5Z PPR:
6Request → Pre-rendered shell → Response (instant)
7          Dynamic parts → Stream
8         [==] 50ms (shell)
9             [======] 750ms (dynamic, streaming)

Liczby na schemacie są poglądowe. Powłoka wychodzi od razu, więc pierwszy bajt nie czeka na najwolniejsze zapytanie.

Lepsze Core Web Vitals

Na dashboardzie każdy widżet z danymi dostaje własną granicę Suspense:

1// Strona z wieloma dynamicznymi sekcjami
2export default function DashboardPage() {
3  return (
4    <div className="dashboard">
5      {/* Statyczny layout - natychmiastowy LCP */}
6      <DashboardHeader />
7      <DashboardNav />
8
9      <main className="dashboard-content">
10        {/* Dynamiczne widgety - streaming */}
11        <Suspense fallback={<WidgetSkeleton />}>
12          <ActiveMissionsWidget />
13        </Suspense>
14
15        <Suspense fallback={<WidgetSkeleton />}>
16          <CrewStatusWidget />
17        </Suspense>
18
19        <Suspense fallback={<WidgetSkeleton />}>
20          <ResourcesWidget />
21        </Suspense>
22      </main>
23    </div>
24  );
25}

Statyczny nagłówek przyspiesza FCP i LCP, a widżety ładują się niezależnie. Ustal budżet wydajności, czyli limity metryk, jak rozmiar paczki czy czas ładowania, i sprawdzaj go przy każdym wdrożeniu.

Hierarchiczny Suspense

Granice można zagnieżdżać, żeby zależne sekcje pojawiały się warstwami:

1export default function MissionDetailsPage({ params }) {
2  // params to obietnica: strona na nią nie czeka, tylko przekazuje ją dalej
3  return (
4    <div className="mission-details">
5      {/* Poziom 1: Główne dane misji */}
6      <Suspense fallback={<MissionHeaderSkeleton />}>
7        <MissionHeader params={params} />
8
9        {/* Poziom 2: Załoga (zależy od danych misji) */}
10        <Suspense fallback={<CrewListSkeleton />}>
11          <CrewList params={params} />
12        </Suspense>
13
14        {/* Poziom 2: Oś czasu */}
15        <Suspense fallback={<TimelineSkeleton />}>
16          <MissionTimeline params={params} />
17        </Suspense>
18      </Suspense>
19    </div>
20  );
21}
22
23async function CrewList({ params }) {
24  const { id } = await params; // Czekamy dopiero wewnątrz granicy Suspense
25  const crew = await getCrew(id);
26
27  return (
28    <ul>
29      {crew.map(member => (
30        <li key={member.id}>{member.name}</li>
31      ))}
32    </ul>
33  );
34}

Załoga i oś czasu pojawią się po nagłówku misji, każda osobno. W Next.js 15 i 16 params jest obietnicą, dlatego strona nie czeka na nią sama, tylko przekazuje ją dzieciom, a id odczytują dopiero komponenty wewnątrz Suspense. Dzięki temu układ strony i fallbacki nadal trafiają do statycznej powłoki.

Praktyczny przykład - Dashboard kosmiczny

Pełny dashboard łączy część statyczną z dwiema sekcjami dynamicznymi:

1// app/dashboard/page.jsx
2import { Suspense } from 'react';
3import { connection } from 'next/server';
4
5// Statyczny układ - trafia do powłoki
6function DashboardLayout({ children }) {
7  return (
8    <div className="space-dashboard">
9      <header className="dashboard-header">
10        <h1>Galaktyczne Centrum Kontroli</h1>
11        <Suspense fallback={<time>...</time>}>
12          <CurrentDate />
13        </Suspense>
14      </header>
15      {children}
16    </div>
17  );
18}
19
20// Data z chwili żądania, a nie z chwili budowania
21async function CurrentDate() {
22  await connection(); // Od tego miejsca renderuj przy żądaniu
23  return <time>{new Date().toLocaleDateString('pl-PL')}</time>;
24}
25
26// Dynamiczny komponent - pobiera dane w czasie rzeczywistym
27async function LiveMissionFeed() {
28  // Bez 'use cache' każde żądanie pobiera świeże dane
29  const missions = await fetch('https://api.space/missions/live').then(r => r.json());
30
31  return (
32    <section className="live-feed">
33      <h2>Aktywne misje</h2>
34      {missions.map(m => (
35        <article key={m.id} className="mission-item">
36          <h3>{m.name}</h3>
37          <p>Status: {m.status}</p>
38          <p>Postęp: {m.progress}%</p>
39        </article>
40      ))}
41    </section>
42  );
43}
44
45// Dynamiczny komponent - powiadomienia
46async function NotificationsPanel() {
47  const notifications = await fetch('https://api.space/notifications').then(r => r.json());
48
49  return (
50    <aside className="notifications">
51      <h2>Powiadomienia</h2>
52      {notifications.map(n => (
53        <div key={n.id} className={`notification ${n.priority}`}>
54          {n.message}
55        </div>
56      ))}
57    </aside>
58  );
59}
60
61// Statyczny komponent - statystyki ogólne
62function GeneralStats() {
63  return (
64    <div className="stats-panel">
65      <h2>Statystyki floty</h2>
66      <ul>
67        <li>Aktywne statki: 47</li>
68        <li>Ukończone misje: 1,234</li>
69        <li>Członkowie załóg: 892</li>
70      </ul>
71    </div>
72  );
73}
74
75// Główna strona z PPR
76export default function DashboardPage() {
77  return (
78    <DashboardLayout>
79      {/* Statyczne - pre-rendered */}
80      <GeneralStats />
81
82      <div className="dynamic-content">
83        {/* Dynamiczne - streaming */}
84        <Suspense fallback={<div className="skeleton feed-skeleton" />}>
85          <LiveMissionFeed />
86        </Suspense>
87
88        <Suspense fallback={<div className="skeleton notif-skeleton" />}>
89          <NotificationsPanel />
90        </Suspense>
91      </div>
92    </DashboardLayout>
93  );
94}

GeneralStats należy do powłoki, a LiveMissionFeed i NotificationsPanel strumieniują się niezależnie. Datę obsługuje osobny komponent CurrentDate: zwykłe new Date() w powłoce zamroziłoby datę z chwili budowania, dlatego Cache Components zgłasza wtedy błąd prerenderingu. await connection() przesuwa renderowanie tego jednego komponentu na czas żądania, a Suspense pokazuje w tym czasie fallback. Datę możesz też wyświetlić w komponencie klienckim.

Kontrolowanie zachowania cache

Z Cache Components o tym, co trafia do powłoki, decydują dyrektywy i funkcje w kodzie komponentów. Dane wspólne dla wszystkich oznaczasz 'use cache' z cacheLife i cacheTag, a dane konkretnego użytkownika czytasz wewnątrz Suspense:

1// app/missions/page.jsx
2import { Suspense } from 'react';
3import { cookies } from 'next/headers';
4import { cacheLife, cacheTag } from 'next/cache';
5
6// Wspólne dla wszystkich - trafia do statycznej powłoki
7async function FleetStats() {
8  'use cache';
9  cacheLife('hours'); // Odświeżanie co godzinę
10  cacheTag('fleet'); // revalidateTag('fleet', 'max') odświeży dane na żądanie
11
12  const stats = await fetch('https://api.space/fleet/stats').then(r => r.json());
13  return <p>Aktywne statki: {stats.activeShips}</p>;
14}
15
16// Zależy od ciasteczka - renderowane przy każdym żądaniu
17async function PilotGreeting() {
18  const pilot = (await cookies()).get('pilot')?.value ?? 'astronauto';
19  return <p>Witaj, {pilot}!</p>;
20}
21
22export default function MissionsPage() {
23  return (
24    <main>
25      <h1>Centrum Kontroli Misji</h1>
26      <FleetStats />
27      <Suspense fallback={<p>Łączenie z pilotem...</p>}>
28        <PilotGreeting />
29      </Suspense>
30    </main>
31  );
32}

Nagłówek i FleetStats trafią do powłoki, bo nie zależą od żądania, a powitanie każdy użytkownik dostanie osobno. Bez Cache Components o renderowaniu decydowały eksporty konfiguracji trasy, jak dynamic = 'force-static', dynamic = 'force-dynamic' czy revalidate. Po włączeniu cacheComponents takie eksporty powodują błąd: zamiast force-static oznaczasz dane dyrektywą 'use cache', zamiast revalidate ustawiasz cacheLife, a force-dynamic jest zbędne, bo bez 'use cache' wszystko jest dynamiczne.

Kiedy używać PPR?

ScenariuszRekomendacja
Landing page z dynamicznym heroPPR
Dashboard z danymi na żywoPPR
E-commerce z cenami/dostępnościąPPR
Blog ze statyczną treściąPełny SSG
Aplikacja SPA bez SEOClient-side
Strona z personalizacjąPPR

Porównanie strategii renderowania

Wybór strategii to część planowania architektury: najpierw analizujesz wymagania, jak świeżość danych, SEO i personalizacja, a potem dobierasz renderowanie:

1┌────────────────┬─────────────────────────────────────────┐
2│ Strategia      │ Charakterystyka                          │
3├────────────────┼─────────────────────────────────────────┤
4│ SSG            │ Całość pre-rendered, brak dynamiki       │
5│ SSR            │ Całość renderowana per-request           │
6│ ISR            │ Pre-rendered z revalidation              │
7│ CSR            │ Całość renderowana na kliencie           │
8│ PPR            │ Statyczny shell + dynamiczny streaming   │
9└────────────────┴─────────────────────────────────────────┘
10
11SSG:   [████████████] → Client (instant, but stale)
12SSR:   [............] → [████████████] → Client (slower)
13PPR:   [████] → [░░░░░░░░] → Client (fast shell + streaming)

PPR łączy SSG i SSR na jednej stronie, nie zastępując ich. W przeglądarce każda strategia kończy się podobnie: najpierw widać HTML (FCP), potem przychodzi JavaScript, a React hydratuje stronę, dołączając obsługę zdarzeń, więc onClick zadziała dopiero po hydracji. Identyfikatory muszą się zgadzać na serwerze i w przeglądarce, więc generuj je przez useId, nie Math.random(). Streaming opiera się na współbieżnym Reakcie z poprzedniej lokacji: createRoot, automatycznym grupowaniu aktualizacji, startTransition i useDeferredValue.

Debugging PPR

Najpewniejszym testem podziału jest produkcyjny build. next build wypisuje tabelę tras, a symbol przy każdej trasie mówi, jak będzie serwowana:

1Route (app)
2┌ ○ /
3├ ○ /about
4└ ◐ /missions
5
6○  (Static)             prerendered as static content
7◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content

○ oznacza stronę w pełni statyczną, a ◐ stronę ze statyczną powłoką i dynamicznymi fragmentami dosyłanymi strumieniowo. Jeśli komponent czyta dane z żądania albo pobiera dane bez cache poza <Suspense>, build zatrzyma się z błędem i podpowie trzy wyjścia: dodać granicę Suspense, zapamiętać dane przez 'use cache' albo świadomie pozwolić trasie blokować renderowanie. Dokładny ślad stosu da next build --debug-prerender, a w trybie deweloperskim te same problemy pokazuje nakładka Next.js. Drzewo komponentów, propsy i stan podejrzysz w React DevTools, a od React 19.2 Chrome DevTools pokazują też ścieżki wydajności Reacta (Scheduler i Components).

Podsumowanie

  1. Najlepsze z dwóch światów - szybkość statycznego + świeżość dynamicznego
  2. Automatyczny podział - Next.js wyznacza powłokę na podstawie granic Suspense i danych
  3. Streaming HTML - użytkownik widzi treść stopniowo
  4. Suspense boundaries - kontrolujesz granularność dynamicznych części
  5. Lepsze Core Web Vitals - szczególnie LCP i FCP

Moja rada: zacznij od statycznej powłoki i przesuwaj granice Suspense jak najniżej, do najmniejszego komponentu, który naprawdę potrzebuje danych z żądania.

Pamiętaj: PPR to hybrydowy napęd statku - statyczna powłoka startuje natychmiast, a dynamiczne moduły dołączają w locie. W przykładzie poniżej kliknij "Nowe żądanie": powłoka z menu i filtrami zostaje na miejscu, a sekcje w granicach Suspense, także gotowy już widżet zasobów, dochodzą od nowa z danymi z kolejnego żądania.

Kod do tej lekcji: App.jsx
1import { Suspense, use, useState } from 'react';
2
3// === PARTIAL PRE-RENDERING (PPR) - symulacja w przeglądarce ===
4// Statyczna powłoka (nagłówek, menu, filtry) jest gotowa od razu, jak HTML zbudowany
5// podczas "next build". Sekcje w granicach Suspense dochodzą strumieniowo przy każdym
6// żądaniu. Przycisk "Nowe żądanie" pokazuje, że powłoka zostaje, a dane są świeże.
7
8const BUILD_TIME = new Date().toLocaleTimeString('pl-PL'); // Moment "budowania" powłoki
9
10const requests = new Map();
11
12// Dane dynamiczne: jedna obietnica na sekcję w danym żądaniu
13function fetchForRequest(section, requestId, delay, makeData) {
14  const key = section + ':' + requestId;
15  if (!requests.has(key)) {
16    requests.set(key, new Promise(resolve => setTimeout(() => {
17      resolve({ data: makeData(requestId), time: new Date().toLocaleTimeString('pl-PL') });
18    }, delay)));
19  }
20  return requests.get(key);
21}
22
23const vary = (base, requestId, spread) => Math.min(100, base + ((requestId * 7) % spread));
24
25// === STATYCZNA POWŁOKA (prerender) ===
26
27function MissionHeader() {
28  return (
29    <header style={styles.header}>
30      <h1 style={styles.headerTitle}>Centrum Kontroli Misji</h1>
31      <nav style={styles.nav}>
32        <a href="#all" style={styles.navLink}>Wszystkie misje</a>
33        <a href="#active" style={{ ...styles.navLink, ...styles.navLinkActive }}>Aktywne</a>
34        <a href="#completed" style={styles.navLink}>Ukończone</a>
35      </nav>
36    </header>
37  );
38}
39
40function MissionSidebar() {
41  const filters = ['Eksploracyjne', 'Badawcze', 'Ratunkowe', 'Transportowe'];
42
43  return (
44    <aside style={styles.sidebar}>
45      <h3 style={{ color: '#00d4ff', marginTop: 0, fontSize: '16px' }}>Filtruj misje</h3>
46      {filters.map(filter => (
47        <label key={filter} style={styles.filterLabel}>
48          <input type="checkbox" style={{ accentColor: '#00d4ff' }} />
49          <span>{filter}</span>
50        </label>
51      ))}
52      <div style={styles.staticBadge}>POWŁOKA STATYCZNA</div>
53      <p style={styles.meta}>Zbudowano o {BUILD_TIME}</p>
54    </aside>
55  );
56}
57
58// === DYNAMICZNE SEKCJE (streaming w granicach Suspense) ===
59
60function StreamedBadge({ requestId, time }) {
61  return (
62    <span style={styles.dynamicBadge}>STREAMING - żądanie #{requestId}, {time}</span>
63  );
64}
65
66function ActiveMissionsWidget({ requestId }) {
67  const { data, time } = use(fetchForRequest('missions', requestId, 1500, id => [
68    { id: 1, name: 'Eksploracja Mgławicy Oriona', progress: vary(70, id, 25) },
69    { id: 2, name: 'Sonda Ciemnej Materii', progress: vary(40, id, 30) },
70    { id: 3, name: 'Patrol Pasa Asteroid', progress: vary(85, id, 15) },
71  ]));
72
73  return (
74    <section style={styles.panel}>
75      <div style={styles.panelHeader}>
76        <h3 style={styles.panelTitle}>Aktywne misje</h3>
77        <StreamedBadge requestId={requestId} time={time} />
78      </div>
79      <div style={styles.missionGrid}>
80        {data.map(m => (
81          <div key={m.id} style={styles.card}>
82            <h4 style={{ margin: '0 0 8px', fontSize: '14px' }}>{m.name}</h4>
83            <div style={styles.progressBar}>
84              <div style={{ ...styles.progressFill, width: m.progress + '%' }} />
85            </div>
86            <span style={{ color: '#00ff88', fontSize: '12px' }}>{m.progress}%</span>
87          </div>
88        ))}
89      </div>
90    </section>
91  );
92}
93
94function CrewStatusWidget({ requestId }) {
95  const { data, time } = use(fetchForRequest('crew', requestId, 2200, id => [
96    { name: 'Komandor Nova', location: 'Mostek', active: true },
97    { name: 'Astro', location: 'Nawigacja', active: true },
98    { name: 'Ra', location: 'Laboratorium', active: id % 2 === 0 },
99  ]));
100
101  return (
102    <section style={styles.panel}>
103      <div style={styles.panelHeader}>
104        <h3 style={styles.panelTitle}>Status załogi</h3>
105        <StreamedBadge requestId={requestId} time={time} />
106      </div>
107      {data.map(c => (
108        <div key={c.name} style={styles.row}>
109          <span>{c.name}</span>
110          <span style={styles.meta}>{c.location}</span>
111          <span style={{ ...styles.dot, background: c.active ? '#00ff88' : '#ffa500' }} />
112        </div>
113      ))}
114    </section>
115  );
116}
117
118function ResourcesWidget({ requestId }) {
119  const { data, time } = use(fetchForRequest('resources', requestId, 3000, id => [
120    { name: 'Paliwo', level: vary(60, id, 30) },
121    { name: 'Tlen', level: vary(85, id, 15) },
122    { name: 'Energia', level: vary(70, id, 25) },
123    { name: 'Woda', level: vary(50, id, 35) },
124    { name: 'Żywność', level: vary(40, id, 40) },
125  ]));
126
127  return (
128    <section style={styles.panel}>
129      <div style={styles.panelHeader}>
130        <h3 style={styles.panelTitle}>Zasoby statku</h3>
131        <StreamedBadge requestId={requestId} time={time} />
132      </div>
133      {data.map(r => (
134        <div key={r.name} style={styles.resource}>
135          <span style={{ width: '70px' }}>{r.name}</span>
136          <div style={styles.progressBar}>
137            <div style={{ ...styles.progressFill, width: r.level + '%', background: r.level < 50 ? '#ffa500' : '#00ff88' }} />
138          </div>
139          <span style={{ width: '40px', textAlign: 'right' }}>{r.level}%</span>
140        </div>
141      ))}
142    </section>
143  );
144}
145
146function TelemetryReport({ requestId }) {
147  const { time } = use(fetchForRequest('telemetry', requestId, 3500, () => null));
148
149  return (
150    <section style={{ ...styles.panel, textAlign: 'center' }}>
151      <StreamedBadge requestId={requestId} time={time} />
152      <p style={{ color: '#64ffda', margin: '8px 0 0' }}>Analiza telemetrii zakończona - wszystkie systemy sprawne.</p>
153    </section>
154  );
155}
156
157function SkeletonCard({ lines = 3 }) {
158  return (
159    <div style={styles.panel}>
160      <div style={{ ...styles.skeletonLine, width: '50%', height: '16px' }} />
161      {Array.from({ length: lines }).map((_, i) => (
162        <div key={i} style={{ ...styles.skeletonLine, width: 85 - i * 12 + '%' }} />
163      ))}
164    </div>
165  );
166}
167
168export default function App() {
169  const [requestId, setRequestId] = useState(1);
170
171  return (
172    <div style={styles.container}>
173      <MissionHeader />
174
175      <div style={styles.layout}>
176        <MissionSidebar />
177
178        <main key={requestId} style={styles.main}>
179          <div style={styles.toolbar}>
180            <button style={styles.button} onClick={() => setRequestId(id => id + 1)}>Nowe żądanie</button>
181            <span style={styles.meta}>Powłoka zostaje na miejscu, dane dochodzą od nowa (żądanie #{requestId})</span>
182          </div>
183
184          <Suspense fallback={<SkeletonCard lines={3} />}>
185            <ActiveMissionsWidget requestId={requestId} />
186          </Suspense>
187
188          <div style={styles.twoColumns}>
189            <Suspense fallback={<SkeletonCard lines={3} />}>
190              <CrewStatusWidget requestId={requestId} />
191            </Suspense>
192            <Suspense fallback={<SkeletonCard lines={5} />}>
193              <ResourcesWidget requestId={requestId} />
194            </Suspense>
195          </div>
196
197          <Suspense fallback={<SkeletonCard lines={1} />}>
198            <TelemetryReport requestId={requestId} />
199          </Suspense>
200        </main>
201      </div>
202      <style>{'@keyframes skeletonPulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }'}</style>
203    </div>
204  );
205}
206
207const styles = {
208  container: { minHeight: '100vh', background: 'linear-gradient(135deg, #0d1b2a 0%, #1a1a3e 100%)', color: '#e0e1dd', fontFamily: 'system-ui, sans-serif', fontSize: '13px' },
209  header: { padding: '16px 24px', borderBottom: '1px solid rgba(0,212,255,0.15)', background: 'rgba(0,0,0,0.2)' },
210  headerTitle: { color: '#00d4ff', margin: '0 0 10px', fontSize: '22px' },
211  nav: { display: 'flex', gap: '16px' },
212  navLink: { color: '#8892b0', textDecoration: 'none', fontSize: '14px', paddingBottom: '4px', borderBottom: '2px solid transparent' },
213  navLinkActive: { color: '#00d4ff', borderBottom: '2px solid #00d4ff' },
214  layout: { display: 'grid', gridTemplateColumns: '200px minmax(0, 1fr)' },
215  sidebar: { padding: '16px', borderRight: '1px solid rgba(0,212,255,0.1)', background: 'rgba(0,0,0,0.15)' },
216  filterLabel: { display: 'flex', alignItems: 'center', gap: '8px', padding: '6px 0', cursor: 'pointer' },
217  staticBadge: { display: 'inline-block', marginTop: '14px', padding: '3px 8px', background: 'rgba(0,212,255,0.15)', border: '1px solid rgba(0,212,255,0.3)', borderRadius: '4px', color: '#00d4ff', fontSize: '10px', fontWeight: 'bold' },
218  main: { padding: '16px', display: 'grid', gap: '12px', alignContent: 'start' },
219  toolbar: { display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap' },
220  button: { padding: '6px 14px', background: '#00d4ff', color: '#0d1b2a', border: 'none', borderRadius: '6px', fontWeight: 'bold', cursor: 'pointer' },
221  meta: { color: '#8892b0', fontSize: '12px' },
222  panel: { background: 'rgba(0,0,0,0.3)', borderRadius: '12px', padding: '14px', border: '1px solid rgba(0,212,255,0.15)' },
223  panelHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px', marginBottom: '10px', flexWrap: 'wrap' },
224  panelTitle: { color: '#00d4ff', margin: 0, fontSize: '15px' },
225  dynamicBadge: { display: 'inline-block', padding: '3px 8px', background: 'rgba(0,255,136,0.12)', border: '1px solid rgba(0,255,136,0.3)', borderRadius: '4px', color: '#00ff88', fontSize: '10px', fontWeight: 'bold' },
226  missionGrid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '10px' },
227  card: { padding: '12px', background: 'rgba(0,0,0,0.3)', borderRadius: '10px', border: '1px solid rgba(0,212,255,0.1)' },
228  progressBar: { flex: 1, height: '6px', background: 'rgba(255,255,255,0.1)', borderRadius: '3px', margin: '6px 0', overflow: 'hidden' },
229  progressFill: { height: '100%', background: 'linear-gradient(90deg, #00d4ff, #00ff88)' },
230  twoColumns: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: '12px' },
231  row: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px', padding: '8px', background: 'rgba(0,0,0,0.2)', borderRadius: '6px', marginBottom: '4px' },
232  dot: { width: '8px', height: '8px', borderRadius: '50%' },
233  resource: { display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '6px' },
234  skeletonLine: { height: '12px', background: 'rgba(0,212,255,0.1)', borderRadius: '4px', marginBottom: '8px', animation: 'skeletonPulse 1.5s infinite' },
235};

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. Na czym polega Partial Pre-rendering (PPR)?

  2. 2. Które narzędzie z ekosystemu React służy do inspekcji drzewa komponentów i ich propsów/stanu w przeglądarce?

To 2 z 4 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Układanie w pionie

    Uporządkuj kroki procesu Server-Side Rendering i hydracji:

  • Układanie w pionie

    Uporządkuj kroki wdrażania funkcji współbieżnych (Concurrent React) w istniejącej aplikacji:

  • Klikanie w kolejności

    Ułóż deklarację i użycie hooka useId do połączenia label z input:

  • Edytor kodu

    Dokończ reducer centrum kontroli misji. Akcja launch startuje zaplanowaną misję, jeśli wystarczy paliwa. ___BLANK1___: nowa wartość paliwa to paliwo ze stanu pomniejszone o fuelCost startującej misji. ___BLANK2___: startująca misja staje się nowym obiektem ze wszystkimi polami starej i statusem 'w locie' (spread, bez zmieniania starego obiektu). ___BLANK3___: App tworzy stan i dispatch przez useReducer z missionReducer i initialState. Reducer nie może mutować stanu: zawsze zwraca nowe obiekty i tablice.

  • Klikanie w kolejności

    Ułóż kod użycia startTransition do oznaczenia aktualizacji jako niepilnej:

  • Edytor kodu

    Spraw, żeby mapa nawigacyjna reagowała płynnie mimo katalogu 3000 gwiazd. ___BLANK1___: deferredQuery to odroczona wersja query z useDeferredValue - pole tekstowe pokazuje wpisany tekst od razu, a lista filtruje się w tle (do tego czasu jest przygaszona, bo isStale jest true). ___BLANK2___: weź isPending i startTransition z hooka useTransition. ___BLANK3___: selectTab zmienia zakładkę przez startTransition(() => setTab(next)), więc w trakcie przełączania pojawia się komunikat z role="status".

  • Układanie w pionie

    Uporządkuj obsługę zdarzeń w React:

  • Edytor kodu

    Dokończ panel Core Web Vitals obserwatorium. Progi (web.dev): LCP dobrze do 2500 ms, wymaga poprawy do 4000 ms; INP dobrze do 200 ms, wymaga poprawy do 500 ms; CLS dobrze do 0.1, wymaga poprawy do 0.25; powyżej jest słabo (poor). ___BLANK1___: rateMetric zwraca needs-improvement, gdy wartość nie przekracza progu poor danego wskaźnika (granica też się liczy). ___BLANK2___: kolor ramki karty to kolor oceny z obiektu COLORS. ___BLANK3___: summarize zwiększa o 1 licznik kategorii, do której trafił wskaźnik. Dla pomiarów z sondy podsumowanie to: 1 dobrze, 1 wymaga poprawy, 1 słabo.

  • Klikanie w kolejności

    Ułóż komponent, który przez useDeferredValue odracza filtrowanie listy gwiazd i przygasza nieaktualny wynik:

  • Edytor kodu

    Zbuduj stronę misji w architekturze App Routera: MissionsPage to asynchroniczny Server Component, a MissionFilter to Client Component ze stanem. ___BLANK1___: MissionsPage czeka na dane z getMissions() (await). ___BLANK2___: przekaż listę misji do MissionFilter przez props missions. ___BLANK3___: gdy zaznaczone jest Tylko aktywne, filtr zostawia misje o statusie 'aktywna'. Server Component nie ma stanu ani obsługi zdarzeń - to wszystko jest w komponencie klienckim. Podgląd w edytorze udaje Next.js, więc strona pojawi się po ok. pół sekundy.

Przydatne artykuły