Cours JavaScript et React · Module 10: Écosystème et avenir de React

React 19.2 - Partial Pre-rendering (PPR)

14 min de lecture
Dans cette leçon9

La page de mission a un en-tête et un menu fixes, mais la liste des missions actives doit être récupérée à chaque requête. La génération statique (SSG) fournit le squelette instantanément, mais avec des données périmées, et le SSR fournit des données fraîches, mais l'utilisateur attend la requête la plus lente. Le Partial Pre-rendering (PPR) réunit les deux mondes sur une seule page, comme un vaisseau dont la trajectoire est calculée avant le décollage et qui corrige son cap quand de nouvelles données arrivent.

Qu'est-ce que le Partial Pre-rendering ?

Le PPR pré-rend les parties statiques d'une page au moment du build, rend les parties dynamiques au moment de la requête et les transmet en streaming au client au fur et à mesure qu'elles sont prêtes. React 19.2 a ajouté à react-dom une API de bas niveau : prerender renvoie une coque prête (prelude) et un état différé (postponed), et resume termine le rendu plus tard. Next.js construit par-dessus une couche pratique. Voici comment la page est découpée :

1┌─────────────────────────────────────────┐
2│           Page Web                       │
3├─────────────────────────────────────────┤
4│  ┌─────────────────────────────────┐    │
5│  │    EN-TÊTE (pre-rendered)        │    │ ← Immédiat
6│  └─────────────────────────────────┘    │
7│  ┌─────────────────────────────────┐    │
8│  │    SIDEBAR (pre-rendered)        │    │ ← Immédiat
9│  └─────────────────────────────────┘    │
10│  ┌─────────────────────────────────┐    │
11│  │    CONTENT (Suspense boundary)   │    │ ← Streaming
12│  │    ┌───────────────────────┐     │    │
13│  │    │  Loading...           │     │    │
14│  │    │  → Dynamic content    │     │    │
15│  │    └───────────────────────┘     │    │
16│  └─────────────────────────────────┘    │
17└─────────────────────────────────────────┘

L'en-tête et la barre latérale arrivent immédiatement dans le navigateur, et le fallback à la place du contenu se transforme en données un instant plus tard. La frontière est marquée par <Suspense>.

Le PPR dans React lui-même : prerender et resume

Next.js n'invente pas ici son propre mécanisme, il utilise l'API ajoutée dans React 19.2. Au moment du build, prerender de react-dom/static rend l'application, et quand tu l'interromps avec le signal d'un AbortController, les frontières Suspense dont les données ne sont pas prêtes entrent dans la coque sous forme de fallback, tandis que leur état est rangé dans l'objet postponed. À la requête, resume de react-dom/server termine le rendu exactement à partir de cet endroit :

1// build.js - étape de build
2import { prerender } from 'react-dom/static';
3
4const controller = new AbortController();
5setTimeout(() => controller.abort(), 100); // Après un instant, arrête d'attendre les données de la requête
6
7const { prelude, postponed } = await prerender(<MissionsPage />, {
8  signal: controller.signal,
9});
10await saveShell(prelude);            // Coque HTML statique, par exemple pour un CDN
11await savePostponedState(postponed); // État des frontières Suspense différées
12
13// server.js - à chaque requête
14import { resume } from 'react-dom/server';
15
16const postponedState = await loadPostponedState();
17const stream = await resume(<MissionsPage />, postponedState);
18// Le navigateur reçoit d'abord la coque depuis le CDN, puis ce stream avec le reste du HTML

prelude est le HTML prêt de la coque, et postponed est un simple objet JSON que tu enregistres et relis à la requête. Si tout a eu le temps d'être rendu, postponed vaut null. Pour générer des pages statiques, il existe aussi resumeAndPrerender de react-dom/static, et dans Node.js les variantes pour les streams Node : resumeToPipeableStream et prerenderToNodeStream.

Comment fonctionne le PPR ?

Configuration dans Next.js

Dans Next.js 15, le PPR était expérimental et s'activait avec le drapeau experimental.ppr. Next.js 16 l'a supprimé, et le PPR est le comportement par défaut une fois les Cache Components activés :

1// next.config.js (Next.js 16+)
2module.exports = {
3  cacheComponents: true, // Active les Cache Components, et avec eux le Partial Pre-rendering
4};

Avec les Cache Components, les données sont dynamiques par défaut, et la coque reçoit ce qui ne lit pas de données de la requête ou ce qui est marqué avec la directive 'use cache'. Les données de la requête, ce sont cookies(), headers(), searchParams, params sans generateStaticParams, les requêtes sans cache et un appel à connection(). Un composant qui les utilise doit se trouver à l'intérieur de <Suspense>, sinon Next.js signale une erreur de prerendering. Les Cache Components exigent l'environnement Node.js, donc les routes avec l'export obsolète runtime = 'edge' doivent être déplacées.

Structure d'une page avec PPR

Une page avec PPR est un composant ordinaire dans lequel tu enveloppes la partie dynamique dans 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// Cette page sera partiellement pré-rendue
9export default function MissionsPage() {
10  return (
11    <div className="missions-layout">
12      {/* Ces composants sont statiques - pre-rendered */}
13      <MissionHeader />
14      <MissionSidebar />
15
16      {/* Ce composant est dynamique - streaming */}
17      <Suspense fallback={<MissionListSkeleton />}>
18        <MissionList />
19      </Suspense>
20    </div>
21  );
22}

MissionHeader et MissionSidebar iront dans la coque statique, et MissionList sera rendu à la requête. La façon d'écrire les composants ne change pas.

Composants statiques (pre-rendered)

Les composants statiques ne récupèrent pas de données et ne lisent rien de la requête, donc le serveur les rend une seule fois, au moment du build :

1// MissionHeader.jsx - composant statique
2export default function MissionHeader() {
3  return (
4    <header className="mission-header">
5      <h1>Centre de contrôle des missions</h1>
6      <nav>
7        <a href="/missions">Toutes les missions</a>
8        <a href="/missions/active">Actives</a>
9        <a href="/missions/completed">Terminées</a>
10      </nav>
11    </header>
12  );
13}
14
15// MissionSidebar.jsx - composant statique
16export default function MissionSidebar() {
17  return (
18    <aside className="mission-sidebar">
19      <h2>Filtrer les missions</h2>
20      <ul>
21        <li>Missions d'exploration</li>
22        <li>Missions de recherche</li>
23        <li>Missions de sauvetage</li>
24      </ul>
25    </aside>
26  );
27}

Leur HTML est le même pour tout le monde, donc il peut être stocké dans un CDN.

Composants dynamiques (streaming)

Un composant dynamique est un Server Component asynchrone qui récupère des données fraîches à chaque requête :

1// MissionList.jsx - Server Component dynamique
2async function getMissions() {
3  // Sans 'use cache', fetch ne mémorise rien : les données sont récupérées à chaque requête
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 pendant le chargement
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}

Avec les Cache Components, un fetch sans la directive 'use cache' ne mémorise rien, donc la liste est récupérée à chaque requête, et en attendant l'arrivée des données tu vois MissionListSkeleton. Le squelette devrait avoir les dimensions de la liste, pour que le contenu ne saute pas.

Avantages du PPR

Time To First Byte (TTFB) plus rapide

Compare la chronologie d'une requête sans PPR et avec PPR :

1Sans PPR :
2Request → Rendu complet → Response
3         [==========] 800ms
4
5Avec PPR :
6Request → Pre-rendered shell → Response (instant)
7          Dynamic parts → Stream
8         [==] 50ms (shell)
9             [======] 750ms (dynamic, streaming)

Les chiffres du schéma sont indicatifs. La coque part immédiatement, donc le premier octet n'attend pas la requête la plus lente.

De meilleurs Core Web Vitals

Sur le tableau de bord, chaque widget avec des données reçoit sa propre frontière Suspense :

1// Page avec plusieurs sections dynamiques
2export default function DashboardPage() {
3  return (
4    <div className="dashboard">
5      {/* Layout statique - LCP instantané */}
6      <DashboardHeader />
7      <DashboardNav />
8
9      <main className="dashboard-content">
10        {/* Widgets dynamiques - 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}

L'en-tête statique accélère le FCP et le LCP, et les widgets se chargent indépendamment. Fixe un budget de performance, c'est-à-dire des limites pour des métriques comme la taille du bundle ou le temps de chargement, et vérifie-le à chaque déploiement.

Suspense hiérarchique

Les frontières peuvent être imbriquées, pour que les sections dépendantes apparaissent par couches :

1export default function MissionDetailsPage({ params }) {
2  // params est une promesse : la page ne l'attend pas, elle la transmet
3  return (
4    <div className="mission-details">
5      {/* Niveau 1 : données principales de la mission */}
6      <Suspense fallback={<MissionHeaderSkeleton />}>
7        <MissionHeader params={params} />
8
9        {/* Niveau 2 : équipage (dépend des données de la mission) */}
10        <Suspense fallback={<CrewListSkeleton />}>
11          <CrewList params={params} />
12        </Suspense>
13
14        {/* Niveau 2 : chronologie */}
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; // On attend seulement à l'intérieur de la frontière 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}

L'équipage et la chronologie apparaîtront après l'en-tête de la mission, chacun séparément. Dans Next.js 15 et 16, params est une promesse, c'est pourquoi la page ne l'attend pas elle-même : elle la transmet aux enfants, et seuls les composants placés dans Suspense lisent id. Grâce à cela, la mise en page et les fallbacks restent dans la coque statique.

Exemple pratique - un tableau de bord spatial

Le tableau de bord complet combine une partie statique et deux sections dynamiques :

1// app/dashboard/page.jsx
2import { Suspense } from 'react';
3import { connection } from 'next/server';
4
5// Mise en page statique - elle va dans la coque
6function DashboardLayout({ children }) {
7  return (
8    <div className="space-dashboard">
9      <header className="dashboard-header">
10        <h1>Centre de contrôle galactique</h1>
11        <Suspense fallback={<time>...</time>}>
12          <CurrentDate />
13        </Suspense>
14      </header>
15      {children}
16    </div>
17  );
18}
19
20// La date de la requête, et non celle du build
21async function CurrentDate() {
22  await connection(); // À partir d'ici, rendu au moment de la requête
23  return <time>{new Date().toLocaleDateString('fr-FR')}</time>;
24}
25
26// Composant dynamique - récupère les données en temps réel
27async function LiveMissionFeed() {
28  // Sans 'use cache', chaque requête récupère des données fraîches
29  const missions = await fetch('https://api.space/missions/live').then(r => r.json());
30
31  return (
32    <section className="live-feed">
33      <h2>Missions actives</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>Progression : {m.progress}%</p>
39        </article>
40      ))}
41    </section>
42  );
43}
44
45// Composant dynamique - 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// Composant statique - statistiques générales
62function GeneralStats() {
63  return (
64    <div className="stats-panel">
65      <h2>Statistiques de la flotte</h2>
66      <ul>
67        <li>Vaisseaux actifs : 47</li>
68        <li>Missions terminées : 1,234</li>
69        <li>Membres d'équipage : 892</li>
70      </ul>
71    </div>
72  );
73}
74
75// Page principale avec PPR
76export default function DashboardPage() {
77  return (
78    <DashboardLayout>
79      {/* Statique - pre-rendered */}
80      <GeneralStats />
81
82      <div className="dynamic-content">
83        {/* Dynamique - 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 fait partie de la coque, tandis que LiveMissionFeed et NotificationsPanel arrivent en streaming indépendamment. La date est gérée par un composant séparé, CurrentDate : un simple new Date() dans la coque figerait la date du moment du build, c'est pourquoi les Cache Components signalent alors une erreur de prerendering. await connection() reporte le rendu de ce seul composant au moment de la requête, et Suspense affiche un fallback en attendant. Tu peux aussi afficher la date dans un composant client.

Contrôler le comportement du cache

Avec les Cache Components, ce qui entre dans la coque dépend des directives et des fonctions dans le code des composants. Tu marques les données communes à tous avec 'use cache', cacheLife et cacheTag, et tu lis les données d'un utilisateur précis à l'intérieur de Suspense :

1// app/missions/page.jsx
2import { Suspense } from 'react';
3import { cookies } from 'next/headers';
4import { cacheLife, cacheTag } from 'next/cache';
5
6// Commun à tous - va dans la coque statique
7async function FleetStats() {
8  'use cache';
9  cacheLife('hours'); // Actualisation toutes les heures
10  cacheTag('fleet'); // revalidateTag('fleet', 'max') actualise les données à la demande
11
12  const stats = await fetch('https://api.space/fleet/stats').then(r => r.json());
13  return <p>Vaisseaux actifs : {stats.activeShips}</p>;
14}
15
16// Dépend d'un cookie - rendu à chaque requête
17async function PilotGreeting() {
18  const pilot = (await cookies()).get('pilot')?.value ?? 'astronaute';
19  return <p>Bienvenue, {pilot} !</p>;
20}
21
22export default function MissionsPage() {
23  return (
24    <main>
25      <h1>Centre de contrôle des missions</h1>
26      <FleetStats />
27      <Suspense fallback={<p>Connexion avec le pilote...</p>}>
28        <PilotGreeting />
29      </Suspense>
30    </main>
31  );
32}

Le titre et FleetStats vont dans la coque, car ils ne dépendent pas de la requête, tandis que chaque utilisateur reçoit son propre message d'accueil. Sans les Cache Components, le rendu était décidé par des exports de configuration de route comme dynamic = 'force-static', dynamic = 'force-dynamic' ou revalidate. Une fois cacheComponents activé, ces exports provoquent une erreur : au lieu de force-static, tu marques les données avec la directive 'use cache', au lieu de revalidate tu définis cacheLife, et force-dynamic devient inutile, car sans 'use cache' tout est dynamique.

Quand utiliser le PPR ?

ScénarioRecommandation
Landing page avec un hero dynamiquePPR
Dashboard avec des données en temps réelPPR
E-commerce avec prix/disponibilitéPPR
Blog au contenu statiqueSSG complet
Application SPA sans SEOClient-side
Page personnaliséePPR

Comparaison des stratégies de rendu

Choisir une stratégie fait partie de la planification de l'architecture : tu analyses d'abord les besoins, comme la fraîcheur des données, le SEO et la personnalisation, puis tu choisis le rendu :

1┌────────────────┬─────────────────────────────────────────┐
2│ Stratégie      │ Caractéristiques                         │
3├────────────────┼─────────────────────────────────────────┤
4│ SSG            │ Tout pre-rendered, rien de dynamique     │
5│ SSR            │ Tout rendu à chaque requête              │
6│ ISR            │ Pre-rendered avec revalidation           │
7│ CSR            │ Tout rendu sur le client                 │
8│ PPR            │ Shell statique + streaming dynamique     │
9└────────────────┴─────────────────────────────────────────┘
10
11SSG:   [████████████] → Client (instant, but stale)
12SSR:   [............] → [████████████] → Client (slower)
13PPR:   [████] → [░░░░░░░░] → Client (fast shell + streaming)

Le PPR combine SSG et SSR sur une seule page, sans les remplacer. Dans le navigateur, chaque stratégie se termine de façon similaire : d'abord le HTML devient visible (FCP), puis le JavaScript arrive, et React hydrate la page en y attachant les gestionnaires d'événements, donc onClick ne fonctionne qu'après l'hydratation. Les identifiants doivent correspondre sur le serveur et dans le navigateur, donc génère-les avec useId, pas avec Math.random(). Le streaming repose sur le React concurrent du module précédent : createRoot, le regroupement automatique des mises à jour, startTransition et useDeferredValue.

Débogage du PPR

Le test le plus fiable du découpage est un build de production. next build affiche un tableau des routes, et le symbole à côté de chaque route indique comment elle sera servie :

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

○ désigne une page entièrement statique, et ◐ une page avec une coque statique et des parties dynamiques envoyées en streaming. Si un composant lit des données de la requête ou récupère des données sans cache en dehors de <Suspense>, le build s'arrête avec une erreur et propose trois solutions : ajouter une frontière Suspense, mettre les données en cache avec 'use cache', ou laisser volontairement la route bloquer le rendu. next build --debug-prerender donne une trace de pile précise, et en développement l'overlay de Next.js montre les mêmes problèmes. Tu peux inspecter l'arbre des composants, les props et l'état dans les React DevTools, et depuis React 19.2 les Chrome DevTools affichent aussi les pistes de performance de React (Scheduler et Components).

Résumé

  1. Le meilleur des deux mondes - la rapidité du statique + la fraîcheur du dynamique
  2. Découpage automatique - Next.js détermine la coque à partir des frontières Suspense et des données
  3. Streaming HTML - l'utilisateur voit le contenu progressivement
  4. Suspense boundaries - tu contrôles la granularité des parties dynamiques
  5. De meilleurs Core Web Vitals - en particulier le LCP et le FCP

Mon conseil : commence par une coque statique et place les frontières Suspense le plus bas possible, jusqu'au plus petit composant qui a vraiment besoin des données de la requête.

Retiens : le PPR est la propulsion hybride du vaisseau - la coque statique décolle instantanément, et les modules dynamiques la rejoignent en plein vol. Dans l'exemple ci-dessous, clique sur « Nouvelle requête » : la coque avec le menu et les filtres reste en place, tandis que les sections dans les frontières Suspense, y compris le widget des ressources désormais terminé, arrivent à nouveau avec les données de la requête suivante.

Code de cette leçon : App.jsx
1import { Suspense, use, useState } from 'react';
2
3// === PARTIAL PRE-RENDERING (PPR) - une simulation dans le navigateur ===
4// La coque statique (en-tête, menu, filtres) est prête tout de suite, comme le HTML construit
5// pendant "next build". Les sections dans les frontières Suspense arrivent en streaming à chaque
6// requête. Le bouton "Nouvelle requête" montre que la coque reste et que les données sont fraîches.
7
8const BUILD_TIME = new Date().toLocaleTimeString('fr-FR'); // Le moment où la coque a été "construite"
9
10const requests = new Map();
11
12// Données dynamiques : une promesse par section et par requête
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('fr-FR') });
18    }, delay)));
19  }
20  return requests.get(key);
21}
22
23const vary = (base, requestId, spread) => Math.min(100, base + ((requestId * 7) % spread));
24
25// === COQUE STATIQUE (prérendu) ===
26
27function MissionHeader() {
28  return (
29    <header style={styles.header}>
30      <h1 style={styles.headerTitle}>Centre de contrôle des missions</h1>
31      <nav style={styles.nav}>
32        <a href="#all" style={styles.navLink}>Toutes les missions</a>
33        <a href="#active" style={{ ...styles.navLink, ...styles.navLinkActive }}>Actives</a>
34        <a href="#completed" style={styles.navLink}>Terminées</a>
35      </nav>
36    </header>
37  );
38}
39
40function MissionSidebar() {
41  const filters = ['Exploration', 'Recherche', 'Sauvetage', 'Transport'];
42
43  return (
44    <aside style={styles.sidebar}>
45      <h3 style={{ color: '#00d4ff', marginTop: 0, fontSize: '16px' }}>Filtrer les 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}>COQUE STATIQUE</div>
53      <p style={styles.meta}>Construite à {BUILD_TIME}</p>
54    </aside>
55  );
56}
57
58// === SECTIONS DYNAMIQUES (streaming dans les frontières Suspense) ===
59
60function StreamedBadge({ requestId, time }) {
61  return (
62    <span style={styles.dynamicBadge}>STREAMING - requête #{requestId}, {time}</span>
63  );
64}
65
66function ActiveMissionsWidget({ requestId }) {
67  const { data, time } = use(fetchForRequest('missions', requestId, 1500, id => [
68    { id: 1, name: 'Exploration de la nébuleuse d’Orion', progress: vary(70, id, 25) },
69    { id: 2, name: 'Sonde de matière noire', progress: vary(40, id, 30) },
70    { id: 3, name: 'Patrouille de la ceinture d’astéroïdes', progress: vary(85, id, 15) },
71  ]));
72
73  return (
74    <section style={styles.panel}>
75      <div style={styles.panelHeader}>
76        <h3 style={styles.panelTitle}>Missions actives</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: 'Commandant Nova', location: 'Passerelle', active: true },
97    { name: 'Astro', location: 'Navigation', active: true },
98    { name: 'Ra', location: 'Laboratoire', active: id % 2 === 0 },
99  ]));
100
101  return (
102    <section style={styles.panel}>
103      <div style={styles.panelHeader}>
104        <h3 style={styles.panelTitle}>État de l’équipage</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: 'Carburant', level: vary(60, id, 30) },
121    { name: 'Oxygène', level: vary(85, id, 15) },
122    { name: 'Énergie', level: vary(70, id, 25) },
123    { name: 'Eau', level: vary(50, id, 35) },
124    { name: 'Nourriture', level: vary(40, id, 40) },
125  ]));
126
127  return (
128    <section style={styles.panel}>
129      <div style={styles.panelHeader}>
130        <h3 style={styles.panelTitle}>Ressources du vaisseau</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' }}>Analyse de la télémétrie terminée - tous les systèmes sont opérationnels.</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)}>Nouvelle requête</button>
181            <span style={styles.meta}>La coque reste en place, les données arrivent à nouveau (requête #{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};

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. En quoi consiste le pré-rendu partiel (PPR) ?

  2. 2. Quel outil de l'écosystème React permet d'inspecter l'arbre des composants ainsi que leurs props/état dans le navigateur ?

Voici 2 des 4 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Tri vertical

    Ordonne les étapes du processus de Server-Side Rendering et d'hydratation :

  • Tri vertical

    Ordonne les étapes d'adoption des fonctionnalités concurrentes (Concurrent React) dans une application existante :

  • Clique dans l'ordre

    Compose la déclaration et l'utilisation du hook useId pour relier un label à un champ :

  • Éditeur de code

    Termine le reducer du centre de contrôle des missions. L'action launch fait décoller une mission planifiée s'il y a assez de carburant. ___BLANK1___ : la nouvelle valeur du carburant est le carburant de l'état moins le fuelCost de la mission qui décolle. ___BLANK2___ : la mission qui décolle devient un nouvel objet avec tous les champs de l'ancien et le statut 'en vol' (spread, sans modifier l'ancien objet). ___BLANK3___ : App crée l'état et dispatch avec useReducer à partir de missionReducer et initialState. Le reducer ne doit pas muter l'état : il renvoie toujours de nouveaux objets et tableaux.

  • Clique dans l'ordre

    Compose le code d'utilisation de startTransition pour marquer une mise à jour comme non urgente :

  • Éditeur de code

    Fais en sorte que la carte de navigation réagisse de façon fluide malgré un catalogue de 3000 étoiles. ___BLANK1___ : deferredQuery est la version différée de query obtenue avec useDeferredValue - le champ texte affiche tout de suite le texte saisi, et la liste se filtre en arrière-plan (en attendant, elle est atténuée, car isStale vaut true). ___BLANK2___ : récupère isPending et startTransition du hook useTransition. ___BLANK3___ : selectTab change d'onglet avec startTransition(() => setTab(next)), donc un message avec role="status" apparaît pendant le changement.

  • Tri vertical

    Ordonne la gestion des événements dans React :

  • Éditeur de code

    Termine le panneau Core Web Vitals de l'observatoire. Seuils (web.dev) : LCP est bon jusqu'à 2500 ms et à améliorer jusqu'à 4000 ms ; INP est bon jusqu'à 200 ms et à améliorer jusqu'à 500 ms ; CLS est bon jusqu'à 0.1 et à améliorer jusqu'à 0.25 ; au-delà, c'est mauvais (poor). ___BLANK1___ : rateMetric renvoie needs-improvement quand la valeur ne dépasse pas le seuil poor de l'indicateur (le seuil lui-même compte). ___BLANK2___ : la couleur de la bordure de la carte est la couleur de l'évaluation dans l'objet COLORS. ___BLANK3___ : summarize augmente de 1 le compteur de la catégorie de l'indicateur. Pour les mesures de la sonde, le résumé donne : 1 bon, 1 à améliorer, 1 mauvais.

  • Clique dans l'ordre

    Compose un composant qui diffère le filtrage de la liste d'étoiles avec useDeferredValue et atténue le résultat périmé :

  • Éditeur de code

    Construis la page des missions dans l'architecture de l'App Router : MissionsPage est un Server Component asynchrone, et MissionFilter est un Client Component avec un état. ___BLANK1___ : MissionsPage attend les données de getMissions() (await). ___BLANK2___ : passe la liste des missions à MissionFilter via la prop missions. ___BLANK3___ : quand Seulement les actives est coché, le filtre garde les missions au statut 'active'. Le Server Component n'a ni état ni gestion d'événements : tout cela se trouve dans le composant client. L'aperçu de l'éditeur imite Next.js, la page apparaît donc après environ une demi-seconde.

Articles utiles