Cours JavaScript et React · Module 8: Patterns React avancés
Concurrent Mode et Suspense
Dans cette leçon9
Imagine un centre de contrôle de mission sur une station spatiale où des centaines de signaux provenant de différents capteurs sont traités simultanément. Certaines données sont urgentes (alarmes), d'autres moins (statistiques météo de planètes lointaines). Le Concurrent Mode de React fonctionne selon un principe similaire - il te permet de prioriser les mises à jour de l'interface pour que l'application reste réactive même pendant des opérations lourdes.
Qu'est-ce que le Concurrent Mode ?
Le Concurrent Mode est un ensemble de mécanismes de React qui permettent d'interrompre et de reprendre le rendu. Traditionnellement, React effectuait le rendu de manière synchrone - quand il commençait une mise à jour, il devait la terminer avant de pouvoir réagir à autre chose. En mode concurrent, React peut :
- Commencer à rendre une mise à jour
- L'interrompre quand une mise à jour plus urgente apparaît
- Revenir au travail interrompu plus tard
C'est comme un contrôleur spatial qui peut mettre de côté l'analyse des données d'une sonde lointaine quand une alerte soudaine concernant un astéroïde qui approche apparaît.
startTransition - prioriser les mises à jour
startTransition est une API qui dit à React : « cette mise à jour n'est pas urgente, tu peux l'interrompre ». C'est idéal pour les opérations qui peuvent attendre un instant, comme filtrer une grande liste ou naviguer entre des onglets.
1import { useState, startTransition } from 'react';
2
3function GalaxySearch() {
4 const [searchInput, setSearchInput] = useState('');
5 const [searchResults, setSearchResults] = useState([]);
6
7 const handleSearch = (value) => {
8 // La mise à jour de l'input est URGENTE - l'utilisateur doit voir ce qu'il tape
9 setSearchInput(value);
10
11 // Le filtrage des résultats est MOINS URGENT
12 startTransition(() => {
13 const filtered = galaxyDatabase.filter(
14 galaxy => galaxy.name.toLowerCase().includes(value.toLowerCase())
15 );
16 setSearchResults(filtered);
17 });
18 };
19
20 return (
21 <div>
22 <input
23 value={searchInput}
24 onChange={(e) => handleSearch(e.target.value)}
25 placeholder="Rechercher des galaxies..."
26 />
27 <GalaxyList results={searchResults} />
28 </div>
29 );
30}Grâce à startTransition, l'input répond immédiatement à la saisie, même si le filtrage de milliers de galaxies prend un instant.
useTransition - suivre l'état de la transition
useTransition est un hook qui, en plus de startTransition, nous donne un flag isPending indiquant si une transition est en cours :
1import { useState, useTransition } from 'react';
2
3function MissionDashboard() {
4 const [activeTab, setActiveTab] = useState('overview');
5 const [isPending, startTransition] = useTransition();
6
7 const switchTab = (tab) => {
8 startTransition(() => {
9 setActiveTab(tab);
10 });
11 };
12
13 return (
14 <div>
15 <nav>
16 {['overview', 'crew', 'systems', 'navigation'].map(tab => (
17 <button
18 key={tab}
19 onClick={() => switchTab(tab)}
20 style={{
21 opacity: isPending && activeTab !== tab ? 0.7 : 1,
22 fontWeight: activeTab === tab ? 'bold' : 'normal',
23 }}
24 >
25 {tab.toUpperCase()}
26 </button>
27 ))}
28 </nav>
29
30 {isPending && <div className="loading-bar">Chargement du panneau...</div>}
31
32 <TabContent tab={activeTab} />
33 </div>
34 );
35}Suspense - attente déclarative des données
Suspense est un composant qui te permet de « suspendre » le rendu jusqu'à ce que les données soient prêtes. Au lieu de gérer manuellement les états de chargement, tu déclares ce qu'il faut afficher pendant l'attente :
1import { Suspense } from 'react';
2
3function SpaceStation() {
4 return (
5 <div>
6 <h1>Station spatiale Alpha</h1>
7
8 <Suspense fallback={<LoadingSpinner message="Chargement des données de l'équipage..." />}>
9 <CrewList />
10 </Suspense>
11
12 <Suspense fallback={<LoadingSpinner message="Vérification des systèmes..." />}>
13 <SystemsStatus />
14 </Suspense>
15 </div>
16 );
17}Chaque section se charge indépendamment - l'utilisateur voit les données de l'équipage dès qu'elles sont prêtes, sans attendre le statut des systèmes.
Suspense avec lazy loading
L'utilisation la plus courante de Suspense est le chargement dynamique de composants à l'aide de React.lazy :
1import { lazy, Suspense } from 'react';
2
3// Composants chargés dynamiquement
4const StarMap = lazy(() => import('./StarMap'));
5const MissionLog = lazy(() => import('./MissionLog'));
6const CrewManagement = lazy(() => import('./CrewManagement'));
7
8function CommandCenter() {
9 const [view, setView] = useState('map');
10
11 return (
12 <div>
13 <nav>
14 <button onClick={() => setView('map')}>Carte stellaire</button>
15 <button onClick={() => setView('log')}>Journal de mission</button>
16 <button onClick={() => setView('crew')}>Équipage</button>
17 </nav>
18
19 <Suspense fallback={<div>Chargement du module...</div>}>
20 {view === 'map' && <StarMap />}
21 {view === 'log' && <MissionLog />}
22 {view === 'crew' && <CrewManagement />}
23 </Suspense>
24 </div>
25 );
26}Suspense imbriqué
Nous pouvons imbriquer les composants Suspense, créant ainsi une hiérarchie de chargement. Le Suspense extérieur capture tout, tandis que ceux à l'intérieur permettent un contrôle plus granulaire :
1function MissionControl() {
2 return (
3 <Suspense fallback={<FullPageLoader />}>
4 <header>
5 <MissionTitle />
6 </header>
7
8 <main>
9 <Suspense fallback={<SectionLoader text="Carte..." />}>
10 <InteractiveStarMap />
11 </Suspense>
12
13 <aside>
14 <Suspense fallback={<SectionLoader text="Statistiques..." />}>
15 <MissionStats />
16 </Suspense>
17
18 <Suspense fallback={<SectionLoader text="Messages..." />}>
19 <CommunicationLog />
20 </Suspense>
21 </aside>
22 </main>
23 </Suspense>
24 );
25}useDeferredValue - valeurs différées
useDeferredValue est un hook qui te permet de différer la mise à jour d'une valeur. React effectue d'abord le rendu avec l'ancienne valeur (mise à jour urgente), puis avec la nouvelle (moins urgente) :
1import { useState, useDeferredValue, useMemo } from 'react';
2
3function PlanetExplorer() {
4 const [filter, setFilter] = useState('');
5 const deferredFilter = useDeferredValue(filter);
6
7 // La liste est rendue avec la valeur de filtre différée
8 const filteredPlanets = useMemo(() => {
9 return allPlanets.filter(p =>
10 p.name.toLowerCase().includes(deferredFilter.toLowerCase())
11 );
12 }, [deferredFilter]);
13
14 const isStale = filter !== deferredFilter;
15
16 return (
17 <div>
18 <input
19 value={filter}
20 onChange={(e) => setFilter(e.target.value)}
21 placeholder="Filtrer les planètes..."
22 />
23 <div style={{ opacity: isStale ? 0.6 : 1 }}>
24 {filteredPlanets.map(planet => (
25 <PlanetCard key={planet.id} planet={planet} />
26 ))}
27 </div>
28 </div>
29 );
30}Quand utiliser chaque API ?
| API | Cas d'usage | Exemple |
|---|---|---|
startTransition | Mises à jour qui peuvent attendre | Changer des filtres, naviguer entre onglets |
useTransition | Comme ci-dessus + indicateur de chargement | Changer de vue avec un état de chargement |
Suspense | Attendre des données ou du code | Lazy loading, récupération de données |
useDeferredValue | Différer le rendu d'une valeur | Filtrer de grandes listes, recherche |
Résumé
Le Concurrent Mode et Suspense sont comme un système avancé de gestion des priorités sur une station spatiale. Au lieu de traiter tous les signaux séquentiellement (en bloquant des alertes importantes pendant l'analyse de données moins importantes), React peut prioriser intelligemment les mises à jour :
- startTransition permet de marquer les mises à jour moins urgentes
- useTransition rapporte en plus l'état de la transition
- Suspense gère de façon déclarative l'attente des données
- useDeferredValue diffère les rendus coûteux
Ces mécanismes fonctionnent ensemble, créant des interfaces réactives même dans des applications complexes avec de grandes quantités de données et des opérations lourdes.
Code de cette leçon : App.jsx
1import React, { useState, useTransition, useDeferredValue, Suspense, lazy, useMemo } from 'react';
2
3// ============================================
4// DEMO : Concurrent Mode et Suspense
5// ============================================
6
7// Simulation d'une API lente
8function fetchGalaxies(filter) {
9 return new Promise(resolve => {
10 setTimeout(() => {
11 const allGalaxies = [
12 'Milky Way', 'Andromeda', 'Triangulum', 'Sombrero',
13 'Tourbillon', 'Œil Noir', 'Centaurus A', 'Cygnus A',
14 'Large Magellanic Cloud', 'Small Magellanic Cloud',
15 'NGC 1300', 'NGC 4414', 'Messier 87', 'Pinwheel',
16 'Cartwheel', 'Hoag', 'Sunflower', 'Tadpole',
17 ];
18 const filtered = allGalaxies.filter(g =>
19 g.toLowerCase().includes(filter.toLowerCase())
20 );
21 resolve(filtered);
22 }, 800);
23 });
24}
25
26// --- SECTION 1 : startTransition et useTransition ---
27function GalaxySearch() {
28 const [searchInput, setSearchInput] = useState('');
29 const [searchResults, setSearchResults] = useState([]);
30 const [isPending, startTransition] = useTransition();
31
32 const handleSearch = async (value) => {
33 setSearchInput(value);
34
35 startTransition(async () => {
36 const results = await fetchGalaxies(value);
37 setSearchResults(results);
38 });
39 };
40
41 return (
42 <section style={sectionStyle}>
43 <h2 style={headingStyle}>useTransition - Recherche de galaxies</h2>
44 <p style={{ fontSize: '13px', opacity: 0.7, marginBottom: '12px' }}>
45 Type a galaxy name. Input responds immediately, the list updates with a delay.
46 </p>
47 <input
48 value={searchInput}
49 onChange={(e) => handleSearch(e.target.value)}
50 placeholder="Rechercher des galaxies..."
51 style={inputStyle}
52 />
53 <div style={{ opacity: isPending ? 0.5 : 1, transition: 'opacity 0.3s' }}>
54 {isPending && <p style={{ color: '#ffd700' }}>Recherche dans le cosmos...</p>}
55 {searchResults.length > 0 ? (
56 <ul style={{ listStyle: 'none', padding: 0 }}>
57 {searchResults.map((galaxy, i) => (
58 <li key={i} style={listItemStyle}>{galaxy}</li>
59 ))}
60 </ul>
61 ) : (
62 !isPending && searchInput && <p style={{ opacity: 0.5 }}>No results</p>
63 )}
64 </div>
65 </section>
66 );
67}
68
69// --- SECTION 2 : useDeferredValue ---
70function HeavyPlanetList({ filter }) {
71 const planets = useMemo(() => {
72 const allPlanets = [];
73 for (let i = 1; i <= 200; i++) {
74 allPlanets.push(`Planète-${String(i).padStart(3, '0')}`);
75 }
76 return allPlanets.filter(p =>
77 p.toLowerCase().includes(filter.toLowerCase())
78 );
79 }, [filter]);
80
81 return (
82 <ul style={{ listStyle: 'none', padding: 0, maxHeight: '200px', overflowY: 'auto' }}>
83 {planets.map((planet, i) => (
84 <li key={i} style={listItemStyle}>{planet}</li>
85 ))}
86 </ul>
87 );
88}
89
90function DeferredValueDemo() {
91 const [filter, setFilter] = useState('');
92 const deferredFilter = useDeferredValue(filter);
93 const isStale = filter !== deferredFilter;
94
95 return (
96 <section style={sectionStyle}>
97 <h2 style={headingStyle}>useDeferredValue - Filtrage des planètes</h2>
98 <p style={{ fontSize: '13px', opacity: 0.7, marginBottom: '12px' }}>
99 The list renders with a delayed filter value.
100 </p>
101 <input
102 value={filter}
103 onChange={(e) => setFilter(e.target.value)}
104 placeholder="Filtrer les planètes..."
105 style={inputStyle}
106 />
107 <div style={{ opacity: isStale ? 0.5 : 1, transition: 'opacity 0.3s' }}>
108 <HeavyPlanetList filter={deferredFilter} />
109 </div>
110 </section>
111 );
112}
113
114// --- SECTION 3 : Suspense avec lazy loading ---
115const StarMap = lazy(() => new Promise(resolve => {
116 setTimeout(() => {
117 resolve({
118 default: () => (
119 <div style={{ padding: '20px', background: '#0d1b2a', borderRadius: '8px' }}>
120 <h3 style={{ color: '#00d4ff' }}>Carte des étoiles</h3>
121 <p>Displaying 1,247 star systems in sector Alpha...</p>
122 <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '8px', marginTop: '12px' }}>
123 {['Sirius', 'Vega', 'Altair', 'Rigel', 'Betelgeuse', 'Polaris', 'Deneb', 'Capella'].map(star => (
124 <div key={star} style={{ padding: '8px', background: '#1a2a3a', borderRadius: '4px', textAlign: 'center', fontSize: '12px' }}>
125 {star}
126 </div>
127 ))}
128 </div>
129 </div>
130 )
131 });
132 }, 1500);
133}));
134
135const MissionLog = lazy(() => new Promise(resolve => {
136 setTimeout(() => {
137 resolve({
138 default: () => (
139 <div style={{ padding: '20px', background: '#0d1b2a', borderRadius: '8px' }}>
140 <h3 style={{ color: '#ffd700' }}>Journal de mission</h3>
141 {['Mission Alpha started', 'Mars orbit achieved', 'Probe sent to Europa', 'Contact with base lost', 'Systems restored'].map((log, i) => (
142 <p key={i} style={{ fontSize: '13px', padding: '4px 0', borderBottom: '1px solid #1a2a3a' }}>
143 [il y a {5 - i}h] {log}
144 </p>
145 ))}
146 </div>
147 )
148 });
149 }, 2000);
150}));
151
152function SuspenseDemo() {
153 const [view, setView] = useState('none');
154
155 return (
156 <section style={sectionStyle}>
157 <h2 style={headingStyle}>Suspense - Lazy loading</h2>
158 <p style={{ fontSize: '13px', opacity: 0.7, marginBottom: '12px' }}>
159 Components loaded dynamically with Suspense fallback.
160 </p>
161 <div style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
162 <button onClick={() => setView('map')} style={view === 'map' ? btnActiveStyle : btnStyle}>
163 Carte des étoiles
164 </button>
165 <button onClick={() => setView('log')} style={view === 'log' ? btnActiveStyle : btnStyle}>
166 Journal de mission
167 </button>
168 </div>
169 <Suspense fallback={
170 <div style={{ padding: '20px', textAlign: 'center', color: '#ffd700' }}>
171 Loading module...
172 </div>
173 }>
174 {view === 'map' && <StarMap />}
175 {view === 'log' && <MissionLog />}
176 </Suspense>
177 </section>
178 );
179}
180
181// --- SECTION 4 : Tabs avec useTransition ---
182function TabsDemo() {
183 const [activeTab, setActiveTab] = useState('overview');
184 const [isPending, startTransition] = useTransition();
185
186 const tabs = [
187 { id: 'overview', label: 'Overview', content: 'Mission status: Active. All systems nominal.' },
188 { id: 'crew', label: 'Crew', content: 'Crew: 6 members. Captain Ra, 2 pilots, 2 engineers, 1 scientist.' },
189 { id: 'systems', label: 'Systèmes', content: 'Moteurs : 85%. Oxygène : 97%. Boucliers : 100%. Communication : Active.' },
190 ];
191
192 const switchTab = (tabId) => {
193 startTransition(() => {
194 setActiveTab(tabId);
195 });
196 };
197
198 const activeContent = tabs.find(t => t.id === activeTab);
199
200 return (
201 <section style={sectionStyle}>
202 <h2 style={headingStyle}>useTransition - Tabs</h2>
203 <div style={{ display: 'flex', gap: '4px', marginBottom: '12px' }}>
204 {tabs.map(tab => (
205 <button
206 key={tab.id}
207 onClick={() => switchTab(tab.id)}
208 style={{
209 ...btnStyle,
210 background: activeTab === tab.id ? '#00d4ff' : '#1a2a3a',
211 color: activeTab === tab.id ? '#000' : '#fff',
212 opacity: isPending ? 0.7 : 1,
213 }}
214 >
215 {tab.label}
216 </button>
217 ))}
218 </div>
219 <div style={{ padding: '12px', background: '#0d1b2a', borderRadius: '6px' }}>
220 {isPending ? (
221 <p style={{ color: '#ffd700' }}>Switching...</p>
222 ) : (
223 <p>{activeContent?.content}</p>
224 )}
225 </div>
226 </section>
227 );
228}
229
230// --- Styles ---
231const sectionStyle = {
232 marginBottom: '24px',
233 padding: '16px',
234 background: 'rgba(255,255,255,0.03)',
235 borderRadius: '12px',
236 border: '1px solid rgba(255,255,255,0.08)',
237};
238const headingStyle = { color: '#00d4ff', marginBottom: '8px', fontSize: '16px' };
239const inputStyle = {
240 width: '100%',
241 padding: '10px',
242 background: '#0d1b2a',
243 border: '1px solid #1a3a5c',
244 borderRadius: '6px',
245 color: '#fff',
246 fontSize: '14px',
247 marginBottom: '12px',
248 boxSizing: 'border-box',
249};
250const listItemStyle = {
251 padding: '6px 10px',
252 background: '#0d1b2a',
253 marginBottom: '4px',
254 borderRadius: '4px',
255 fontSize: '13px',
256};
257const btnStyle = {
258 padding: '8px 14px',
259 background: '#1a2a3a',
260 border: 'none',
261 borderRadius: '6px',
262 color: '#fff',
263 cursor: 'pointer',
264 fontSize: '13px',
265};
266const btnActiveStyle = {
267 ...btnStyle,
268 background: '#00d4ff',
269 color: '#000',
270};
271
272// --- App ---
273function App() {
274 return (
275 <div style={{ padding: '20px', background: '#0a0e1a', color: '#e0e0e0', minHeight: '100vh', fontFamily: 'system-ui' }}>
276 <h1 style={{ color: '#ffd700', textAlign: 'center', marginBottom: '24px' }}>
277 Concurrent Mode et Suspense
278 </h1>
279 <GalaxySearch />
280 <DeferredValueDemo />
281 <SuspenseDemo />
282 <TabsDemo />
283 </div>
284 );
285}
286
287export default App;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. Quand vaut-il mieux utiliser un Higher-Order Component (HOC) plutôt qu'un custom hook ?
Tâches pratiques dans le jeu
- Tri horizontal
Ordonne la syntaxe pour utiliser startTransition afin de marquer une mise à jour moins urgente :
- Éditeur de code
Système Compound Components
- Tri vertical
Ordonne les étapes de création d'un custom hook en React :
- Éditeur de code
State machine avec Context API