JavaScript and React course Β· Module 10: React Ecosystem and Future

React 19.2 - Partial Pre-rendering (PPR)

13 min read
In this lesson9

The mission page has a fixed header and menu, but the list of active missions has to be fetched on every request. Static generation (SSG) delivers the skeleton instantly, but with stale data, and SSR delivers fresh data, but the user waits for the slowest query. Partial Pre-rendering (PPR) combines both worlds on a single page, like a ship with a route calculated before launch that corrects its course when new data arrives.

What Is Partial Pre-rendering?

PPR pre-renders the static parts of a page at build time, renders the dynamic ones at request time and streams them to the client as they become ready. React 19.2 added a low-level API to react-dom: prerender returns a ready shell (prelude) and a postponed state (postponed), and resume finishes the rendering later. Next.js builds a convenient layer on top of it. This is how the page is split:

1β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
2β”‚           Web Page                       β”‚
3β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
4β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”‚
5β”‚  β”‚    HEADER (pre-rendered)         β”‚    β”‚ ← Instant
6β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚
7β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”‚
8β”‚  β”‚    SIDEBAR (pre-rendered)        β”‚    β”‚ ← Instant
9β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚
10β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”‚
11β”‚  β”‚    CONTENT (Suspense boundary)   β”‚    β”‚ ← Streaming
12β”‚  β”‚    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”‚    β”‚
13β”‚  β”‚    β”‚  Loading...           β”‚     β”‚    β”‚
14β”‚  β”‚    β”‚  β†’ Dynamic content    β”‚     β”‚    β”‚
15β”‚  β”‚    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β”‚    β”‚
16β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚
17β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The header and sidebar reach the browser immediately, and the fallback in place of the content turns into data a moment later. The boundary is marked by <Suspense>.

PPR in React Itself: prerender and resume

Next.js does not invent its own mechanism here, it uses the API added in React 19.2. At build time prerender from react-dom/static renders the app, and when you abort it with an AbortController signal, the Suspense boundaries whose data is not ready go into the shell as fallbacks, while their state lands in the postponed object. On a request, resume from react-dom/server finishes the rendering from exactly that point:

1// build.js - build step
2import { prerender } from 'react-dom/static';
3
4const controller = new AbortController();
5setTimeout(() => controller.abort(), 100); // After a moment, stop waiting for request data
6
7const { prelude, postponed } = await prerender(<MissionsPage />, {
8  signal: controller.signal,
9});
10await saveShell(prelude);            // Static HTML shell, e.g. for a CDN
11await savePostponedState(postponed); // State of the postponed Suspense boundaries
12
13// server.js - on every request
14import { resume } from 'react-dom/server';
15
16const postponedState = await loadPostponedState();
17const stream = await resume(<MissionsPage />, postponedState);
18// The browser first gets the shell from the CDN, then this stream with the rest of the HTML

prelude is the ready HTML of the shell, and postponed is a plain JSON object that you save and read back on a request. If everything managed to render, postponed is null. For generating static pages there is also resumeAndPrerender from react-dom/static, and in Node.js the variants for Node streams: resumeToPipeableStream and prerenderToNodeStream.

How Does PPR Work?

Configuration in Next.js

In Next.js 15 PPR was experimental and was switched on with the experimental.ppr flag. Next.js 16 removed it, and PPR is the default behavior once Cache Components are enabled:

1// next.config.js (Next.js 16+)
2module.exports = {
3  cacheComponents: true, // Enables Cache Components, and with them Partial Pre-rendering
4};

With Cache Components data is dynamic by default, and the shell gets whatever does not read request data or is marked with the 'use cache' directive. Request data means cookies(), headers(), searchParams, params without generateStaticParams, uncached fetches and a call to connection(). A component that uses them has to sit inside <Suspense>, otherwise Next.js reports a prerendering error. Cache Components requires the Node.js runtime, so routes with the deprecated runtime = 'edge' export have to be moved.

Page Structure with PPR

A page with PPR is a regular component in which you wrap the dynamic part in 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// This page will be partially pre-rendered
9export default function MissionsPage() {
10  return (
11    <div className="missions-layout">
12      {/* These components are static - pre-rendered */}
13      <MissionHeader />
14      <MissionSidebar />
15
16      {/* This component is dynamic - streaming */}
17      <Suspense fallback={<MissionListSkeleton />}>
18        <MissionList />
19      </Suspense>
20    </div>
21  );
22}

MissionHeader and MissionSidebar will go into the static shell, and MissionList will render on request. The way you write components does not change.

Static Components (pre-rendered)

Static components do not fetch data or read anything from the request, so the server renders them once, at build time:

1// MissionHeader.jsx - static component
2export default function MissionHeader() {
3  return (
4    <header className="mission-header">
5      <h1>Mission Control Center</h1>
6      <nav>
7        <a href="/missions">All Missions</a>
8        <a href="/missions/active">Active</a>
9        <a href="/missions/completed">Completed</a>
10      </nav>
11    </header>
12  );
13}
14
15// MissionSidebar.jsx - static component
16export default function MissionSidebar() {
17  return (
18    <aside className="mission-sidebar">
19      <h2>Filter Missions</h2>
20      <ul>
21        <li>Exploration missions</li>
22        <li>Research missions</li>
23        <li>Rescue missions</li>
24      </ul>
25    </aside>
26  );
27}

Their HTML is the same for everyone, so it can sit in a CDN.

Dynamic Components (streaming)

A dynamic component is an asynchronous Server Component that fetches fresh data on every request:

1// MissionList.jsx - dynamic Server Component
2async function getMissions() {
3  // Without 'use cache' fetch stores nothing: the data is fetched on every request
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 during loading
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}

With Cache Components a fetch without the 'use cache' directive stores nothing, so the list is fetched on every request, and until the data arrives you see MissionListSkeleton. The skeleton should have the dimensions of the list, so the content does not jump.

Benefits of PPR

Faster Time To First Byte (TTFB)

Compare the request timeline without PPR and with PPR:

1Without PPR:
2Request β†’ Render everything β†’ Response
3         [==========] 800ms
4
5With PPR:
6Request β†’ Pre-rendered shell β†’ Response (instant)
7          Dynamic parts β†’ Stream
8         [==] 50ms (shell)
9             [======] 750ms (dynamic, streaming)

The numbers in the diagram are illustrative. The shell goes out immediately, so the first byte does not wait for the slowest query.

Better Core Web Vitals

On the dashboard, every widget with data gets its own Suspense boundary:

1// Page with multiple dynamic sections
2export default function DashboardPage() {
3  return (
4    <div className="dashboard">
5      {/* Static layout - instant LCP */}
6      <DashboardHeader />
7      <DashboardNav />
8
9      <main className="dashboard-content">
10        {/* Dynamic widgets - 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}

The static header speeds up FCP and LCP, and the widgets load independently. Set a performance budget, meaning limits for metrics such as bundle size or load time, and check it on every deployment.

Hierarchical Suspense

Boundaries can be nested, so that dependent sections appear in layers:

1export default function MissionDetailsPage({ params }) {
2  // params is a promise: the page does not wait for it, it passes it on
3  return (
4    <div className="mission-details">
5      {/* Level 1: Main mission data */}
6      <Suspense fallback={<MissionHeaderSkeleton />}>
7        <MissionHeader params={params} />
8
9        {/* Level 2: Crew (depends on mission data) */}
10        <Suspense fallback={<CrewListSkeleton />}>
11          <CrewList params={params} />
12        </Suspense>
13
14        {/* Level 2: Timeline */}
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; // We wait only inside the Suspense boundary
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}

The crew and the timeline will appear after the mission header, each one separately. In Next.js 15 and 16 params is a promise, which is why the page does not wait for it itself, it passes it to the children, and only the components inside Suspense read id. Thanks to that the page layout and the fallbacks still go into the static shell.

Practical Example - Space Dashboard

The full dashboard combines a static part with two dynamic sections:

1// app/dashboard/page.jsx
2import { Suspense } from 'react';
3import { connection } from 'next/server';
4
5// Static layout - goes into the shell
6function DashboardLayout({ children }) {
7  return (
8    <div className="space-dashboard">
9      <header className="dashboard-header">
10        <h1>Galactic Control Center</h1>
11        <Suspense fallback={<time>...</time>}>
12          <CurrentDate />
13        </Suspense>
14      </header>
15      {children}
16    </div>
17  );
18}
19
20// The date of the request, not of the build
21async function CurrentDate() {
22  await connection(); // From here on, render at request time
23  return <time>{new Date().toLocaleDateString('en-US')}</time>;
24}
25
26// Dynamic component - fetches real-time data
27async function LiveMissionFeed() {
28  // Without 'use cache' every request fetches fresh data
29  const missions = await fetch('https://api.space/missions/live').then(r => r.json());
30
31  return (
32    <section className="live-feed">
33      <h2>Active Missions</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>Progress: {m.progress}%</p>
39        </article>
40      ))}
41    </section>
42  );
43}
44
45// Dynamic component - notifications
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>Notifications</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// Static component - general statistics
62function GeneralStats() {
63  return (
64    <div className="stats-panel">
65      <h2>Fleet Statistics</h2>
66      <ul>
67        <li>Active ships: 47</li>
68        <li>Completed missions: 1,234</li>
69        <li>Crew members: 892</li>
70      </ul>
71    </div>
72  );
73}
74
75// Main page with PPR
76export default function DashboardPage() {
77  return (
78    <DashboardLayout>
79      {/* Static - pre-rendered */}
80      <GeneralStats />
81
82      <div className="dynamic-content">
83        {/* Dynamic - 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 belongs to the shell, while LiveMissionFeed and NotificationsPanel stream independently. The date is handled by a separate CurrentDate component: a plain new Date() in the shell would freeze the date from build time, which is why Cache Components reports a prerendering error then. await connection() moves the rendering of this one component to request time, and Suspense shows a fallback in the meantime. You can also display the date in a client component.

Controlling Cache Behavior

With Cache Components, what goes into the shell is decided by directives and functions in the component code. You mark data shared by everyone with 'use cache' plus cacheLife and cacheTag, and you read a specific user's data inside Suspense:

1// app/missions/page.jsx
2import { Suspense } from 'react';
3import { cookies } from 'next/headers';
4import { cacheLife, cacheTag } from 'next/cache';
5
6// Shared by everyone - goes into the static shell
7async function FleetStats() {
8  'use cache';
9  cacheLife('hours'); // Refresh every hour
10  cacheTag('fleet'); // revalidateTag('fleet', 'max') refreshes the data on demand
11
12  const stats = await fetch('https://api.space/fleet/stats').then(r => r.json());
13  return <p>Active ships: {stats.activeShips}</p>;
14}
15
16// Depends on a cookie - rendered on every request
17async function PilotGreeting() {
18  const pilot = (await cookies()).get('pilot')?.value ?? 'astronaut';
19  return <p>Welcome, {pilot}!</p>;
20}
21
22export default function MissionsPage() {
23  return (
24    <main>
25      <h1>Mission Control Center</h1>
26      <FleetStats />
27      <Suspense fallback={<p>Connecting to the pilot...</p>}>
28        <PilotGreeting />
29      </Suspense>
30    </main>
31  );
32}

The heading and FleetStats go into the shell because they do not depend on the request, while every user gets their own greeting. Without Cache Components, rendering was decided by route segment config exports such as dynamic = 'force-static', dynamic = 'force-dynamic' or revalidate. Once cacheComponents is enabled, these exports cause an error: instead of force-static you mark the data with the 'use cache' directive, instead of revalidate you set cacheLife, and force-dynamic is unnecessary, because without 'use cache' everything is dynamic.

When to Use PPR?

ScenarioRecommendation
Landing page with a dynamic heroPPR
Dashboard with real-time dataPPR
E-commerce with prices/availabilityPPR
Blog with static contentFull SSG
SPA application without SEOClient-side
Page with personalizationPPR

Comparing Rendering Strategies

Choosing a strategy is part of architecture planning: first you analyze the requirements, such as data freshness, SEO and personalization, and then you pick the rendering:

1β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
2β”‚ Strategy       β”‚ Characteristics                          β”‚
3β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
4β”‚ SSG            β”‚ Entirely pre-rendered, no dynamics       β”‚
5β”‚ SSR            β”‚ Entirely rendered per request            β”‚
6β”‚ ISR            β”‚ Pre-rendered with revalidation           β”‚
7β”‚ CSR            β”‚ Entirely rendered on the client          β”‚
8β”‚ PPR            β”‚ Static shell + dynamic streaming         β”‚
9β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
10
11SSG:   [β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ] β†’ Client (instant, but stale)
12SSR:   [............] β†’ [β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ] β†’ Client (slower)
13PPR:   [β–ˆβ–ˆβ–ˆβ–ˆ] β†’ [β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘] β†’ Client (fast shell + streaming)

PPR combines SSG and SSR on a single page without replacing them. In the browser every strategy ends in a similar way: first the HTML becomes visible (FCP), then the JavaScript arrives, and React hydrates the page by attaching event handlers, so onClick works only after hydration. Identifiers must match on the server and in the browser, so generate them with useId, not Math.random(). Streaming builds on concurrent React from the previous location: createRoot, automatic batching of updates, startTransition and useDeferredValue.

Debugging PPR

The most reliable test of the split is a production build. next build prints a route table, and the symbol next to each route tells you how it will be served:

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

β—‹ means a fully static page, and ◐ a page with a static shell and dynamic parts streamed in. If a component reads request data or fetches uncached data outside <Suspense>, the build stops with an error and suggests three ways out: add a Suspense boundary, cache the data with 'use cache', or knowingly let the route block rendering. next build --debug-prerender gives you a precise stack trace, and in development the Next.js overlay shows the same problems. You can inspect the component tree, props and state in React DevTools, and since React 19.2 Chrome DevTools also show React performance tracks (Scheduler and Components).

Summary

  1. The best of both worlds - the speed of static + the freshness of dynamic
  2. Automatic splitting - Next.js determines the shell based on Suspense boundaries and data
  3. HTML streaming - the user sees content progressively
  4. Suspense boundaries - you control the granularity of the dynamic parts
  5. Better Core Web Vitals - especially LCP and FCP

My advice: start with a static shell and push the Suspense boundaries as low as possible, down to the smallest component that really needs request data.

Remember: PPR is the ship's hybrid drive - the static shell launches instantly, and the dynamic modules join in flight. In the example below click "New request": the shell with the menu and filters stays in place, while the sections inside Suspense boundaries, including the now finished resources widget, stream in again with data from the next request.

Code for this lesson: App.jsx
1import { Suspense, use, useState } from 'react';
2
3// === PARTIAL PRE-RENDERING (PPR) - a simulation in the browser ===
4// The static shell (header, menu, filters) is ready immediately, like HTML built
5// during "next build". Sections inside Suspense boundaries stream in on every
6// request. The "New request" button shows that the shell stays while the data is fresh.
7
8const BUILD_TIME = new Date().toLocaleTimeString('en-US'); // The moment the shell was "built"
9
10const requests = new Map();
11
12// Dynamic data: one promise per section in a given request
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('en-US') });
18    }, delay)));
19  }
20  return requests.get(key);
21}
22
23const vary = (base, requestId, spread) => Math.min(100, base + ((requestId * 7) % spread));
24
25// === STATIC SHELL (prerendered) ===
26
27function MissionHeader() {
28  return (
29    <header style={styles.header}>
30      <h1 style={styles.headerTitle}>Mission Control Center</h1>
31      <nav style={styles.nav}>
32        <a href="#all" style={styles.navLink}>All missions</a>
33        <a href="#active" style={{ ...styles.navLink, ...styles.navLinkActive }}>Active</a>
34        <a href="#completed" style={styles.navLink}>Completed</a>
35      </nav>
36    </header>
37  );
38}
39
40function MissionSidebar() {
41  const filters = ['Exploration', 'Research', 'Rescue', 'Transport'];
42
43  return (
44    <aside style={styles.sidebar}>
45      <h3 style={{ color: '#00d4ff', marginTop: 0, fontSize: '16px' }}>Filter missions</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}>STATIC SHELL</div>
53      <p style={styles.meta}>Built at {BUILD_TIME}</p>
54    </aside>
55  );
56}
57
58// === DYNAMIC SECTIONS (streaming inside Suspense boundaries) ===
59
60function StreamedBadge({ requestId, time }) {
61  return (
62    <span style={styles.dynamicBadge}>STREAMING - request #{requestId}, {time}</span>
63  );
64}
65
66function ActiveMissionsWidget({ requestId }) {
67  const { data, time } = use(fetchForRequest('missions', requestId, 1500, id => [
68    { id: 1, name: 'Orion Nebula Exploration', progress: vary(70, id, 25) },
69    { id: 2, name: 'Dark Matter Probe', progress: vary(40, id, 30) },
70    { id: 3, name: 'Asteroid Belt Patrol', progress: vary(85, id, 15) },
71  ]));
72
73  return (
74    <section style={styles.panel}>
75      <div style={styles.panelHeader}>
76        <h3 style={styles.panelTitle}>Active missions</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: 'Commander Nova', location: 'Bridge', active: true },
97    { name: 'Astro', location: 'Navigation', active: true },
98    { name: 'Ra', location: 'Laboratory', active: id % 2 === 0 },
99  ]));
100
101  return (
102    <section style={styles.panel}>
103      <div style={styles.panelHeader}>
104        <h3 style={styles.panelTitle}>Crew status</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: 'Fuel', level: vary(60, id, 30) },
121    { name: 'Oxygen', level: vary(85, id, 15) },
122    { name: 'Energy', level: vary(70, id, 25) },
123    { name: 'Water', level: vary(50, id, 35) },
124    { name: 'Food', level: vary(40, id, 40) },
125  ]));
126
127  return (
128    <section style={styles.panel}>
129      <div style={styles.panelHeader}>
130        <h3 style={styles.panelTitle}>Ship resources</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' }}>Telemetry analysis complete - all systems operational.</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)}>New request</button>
181            <span style={styles.meta}>The shell stays in place, the data streams in again (request #{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};

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What is Partial Pre-rendering (PPR)?

  2. 2. Which tool from the React ecosystem is used to inspect the component tree and their props/state in the browser?

These are 2 of 4 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Vertical ordering

    Order the steps of the Server-Side Rendering and hydration process:

  • Vertical ordering

    Order the steps of adopting concurrent features (Concurrent React) in an existing app:

  • Click in order

    Arrange the declaration and usage of the useId hook to connect a label with an input:

  • Code editor

    Finish the mission control reducer. The launch action launches a planned mission if there is enough fuel. ___BLANK1___: the new fuel value is the fuel from state minus the fuelCost of the launched mission. ___BLANK2___: the launched mission becomes a new object with all the fields of the old one and the status 'in flight' (spread, without changing the old object). ___BLANK3___: App creates the state and dispatch with useReducer from missionReducer and initialState. The reducer must not mutate state: it always returns new objects and arrays.

  • Click in order

    Arrange the code for using startTransition to mark an update as non-urgent:

  • Code editor

    Make the navigation map respond smoothly despite a catalogue of 3000 stars. ___BLANK1___: deferredQuery is the deferred version of query from useDeferredValue - the text field shows the typed text at once, while the list filters in the background (until then it is dimmed, because isStale is true). ___BLANK2___: take isPending and startTransition from the useTransition hook. ___BLANK3___: selectTab changes the tab with startTransition(() => setTab(next)), so a message with role="status" shows up while switching.

  • Vertical ordering

    Order event handling in React:

  • Code editor

    Finish the Core Web Vitals panel of the observatory. Thresholds (web.dev): LCP is good up to 2500 ms and needs improvement up to 4000 ms; INP is good up to 200 ms and needs improvement up to 500 ms; CLS is good up to 0.1 and needs improvement up to 0.25; above that it is poor. ___BLANK1___: rateMetric returns needs-improvement when the value does not exceed the poor threshold of that metric (the threshold itself counts). ___BLANK2___: the card border colour is the colour of the rating from the COLORS object. ___BLANK3___: summarize increases by 1 the counter of the category the metric falls into. For the probe measurements the summary is: 1 good, 1 needs improvement, 1 poor.

  • Click in order

    Arrange a component that defers filtering the star list with useDeferredValue and dims the stale result:

  • Code editor

    Build the missions page in the App Router architecture: MissionsPage is an async Server Component, and MissionFilter is a Client Component with state. ___BLANK1___: MissionsPage waits for the data from getMissions() (await). ___BLANK2___: pass the mission list to MissionFilter through the missions prop. ___BLANK3___: when Active only is checked, the filter keeps the missions with the status 'active'. The Server Component has no state and no event handling - all of that lives in the client component. The editor preview pretends to be Next.js, so the page shows up after about half a second.

Useful articles