Cours JavaScript et React · Module 8: Patterns React avancés

Concurrent Mode et Suspense

6 min de lecture
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 ?

APICas d'usageExemple
startTransitionMises à jour qui peuvent attendreChanger des filtres, naviguer entre onglets
useTransitionComme ci-dessus + indicateur de chargementChanger de vue avec un état de chargement
SuspenseAttendre des données ou du codeLazy loading, récupération de données
useDeferredValueDifférer le rendu d'une valeurFiltrer 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. 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

Articles utiles