Front-end angularforall.com

- React 18 : rendu concurrent

React-18 Concurrent-Rendering Usetransition Usedeferredvalue Suspense Automatic-Batching Createroot Ssr-Streaming Fiber Web-Vitals Inp React-19
React 18 : rendu concurrent

React 18 rendu concurrent : useTransition, useDeferredValue, Suspense data fetching, automatic batching, streaming SSR et metriques INP Web Vitals.

Pourquoi le rendu concurrent change tout

React 18 a été le plus gros changement architectural du framework depuis Hooks. La promesse en une phrase : « votre application reste fluide même quand React travaille beaucoup ». La métrique qui en bénéficie directement, l'INP (Interaction to Next Paint), est devenue une Web Vital officielle de Google en 2024 et impacte directement votre SEO mobile.

Le rendu concurrent est opt-in : il s'active uniquement quand vous utilisez les APIs prévues — createRoot(), useTransition, useDeferredValue, Suspense pour data fetching, startTransition, et le streaming SSR. Tant que vous ne touchez à rien, votre code se comporte comme en React 17, sauf l'automatic batching qui est activé par défaut dès la migration vers createRoot().

Ce que cet article couvre

  • Le modèle avant/après — pourquoi React était bloquant, et ce qui a changé.
  • Le moteur interne — Fiber, time slicing, scheduler, priorités.
  • Les cinq APIs à connaître : createRoot, useTransition, useDeferredValue, Suspense, startTransition.
  • L'automatic batching et flushSync pour les exceptions.
  • Le streaming SSR via renderToPipeableStream.
  • Comment mesurer le gain avec INP et la lib web-vitals.
  • Les pièges classiques et la suite avec React 19+ et le compiler.
À retenir : le rendu concurrent ne rend pas votre code plus rapide à exécuter ; il rend la réactivité ressentie meilleure en répartissant le travail dans le temps et en priorisant ce que l'utilisateur perçoit. C'est une amélioration de UX, pas de performance brute.

Cet article s'adresse aux développeurs React qui maîtrisent déjà les Hooks de base (useState, useEffect, useMemo) et qui veulent comprendre ce qu'apporte concrètement React 18 et au-delà. Tous les exemples sont écrits en TypeScript ES2023, compatibles avec une app créée par Vite, Next.js 14+ ou Remix. Ils s'exécutent tels quels — vous pouvez les coller dans un projet existant pour valider chaque pattern.

Avant React 18 — le modèle synchrone bloquant

Jusqu'à React 17, chaque cycle de rendu était atomique et synchrone. Une fois démarré, il devait se terminer avant que le navigateur ne reprenne la main. Sur une liste de 10 000 éléments avec un filtre, l'utilisateur tapait dans l'input, React commençait son rendu de ~300 ms, et pendant ces 300 ms : pas de saisie suivante, pas de scroll, pas d'animation CSS. L'application paraissait gelée.

// React 17 — modèle synchrone bloquant
function SlowSearch() {
  const [text, setText] = useState('');
  const filtered = bigList.filter(x => x.name.includes(text)); // 300 ms

  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <List items={filtered} /> {/* bloque l'UI à chaque frappe */}
    </>
  );
}

Les contournements de l'époque

  • Debounce manuel — attendre 300 ms après la dernière frappe pour déclencher le filtre. Résultat : l'utilisateur voit une latence visible avant que les résultats apparaissent.
  • Pagination virtuelle — ne rendre que les 50 premiers items. Solution lourde à implémenter, qui ne résout pas le problème pour les écrans qui ont vraiment besoin de tout afficher.
  • Web Workers — déplacer le filtrage hors du thread principal. Complexe, et inadapté quand le coût est dans le rendu React, pas dans le calcul JS.
  • useMemo + React.memo — bonne pratique mais n'empêche pas le rendu initial bloquant.

Le rendu concurrent attaque le problème à la racine : pourquoi React doit-il faire tout son travail en une fois ? Pourquoi ne peut-il pas s'interrompre quand l'utilisateur tape, pour reprendre ensuite ? La réponse de React 18 : il le peut, et c'est le mode par défaut dès qu'on l'active.

Le moteur du rendu concurrent (Fiber, time slicing)

Le moteur s'appelle Fiber. Introduit dès React 16 (2017), il prépare le terrain en transformant l'arbre de composants en une structure linéarisée que React peut parcourir nœud par nœud, avec la possibilité d'interrompre entre deux nœuds. React 18 active enfin cette capacité d'interruption — c'est le time slicing.

Comment React 18 décide d'interrompre

Pendant un rendu, React vérifie périodiquement (~ toutes les 5 ms) si le navigateur a du travail prioritaire en attente : un événement clavier, une animation, un layout shift. Si oui, React pause son rendu, rend la main, laisse le navigateur traiter l'événement, puis reprend où il s'était arrêté.

// Schéma simplifié de l'exécution
[Render start] → bloc1 → bloc2 → [navigateur a un click]
                                        ↓
                                  React pause
                                        ↓
                                  Navigateur traite click
                                        ↓
                                  React reprend → bloc3 → ...

// En React 17 : pas de pause possible — le click est mis en file
// jusqu'à la fin du rendu, même si ça prend 300 ms.

Les niveaux de priorité React 18

PrioritéType d'updateExemples
ImmediateSynchrone, jamais interrompueflushSync, certaines mesures DOM
UserBlockingÉvénements utilisateur directsSaisie clavier, clic, focus
NormalMises à jour standardRéponse réseau, timer
TransitionInterruptible, basse prioritéuseTransition, useDeferredValue
IdleQuand le navigateur est librePré-fetch, hydration différée

Vous n'interagissez jamais directement avec ces niveaux — vous les marquez via les hooks. Mais comprendre la hiérarchie permet de raisonner sur pourquoi useTransition rend une UI fluide : il fait passer un rendu coûteux du niveau Normal au niveau Transition, ce qui permet aux clicks et frappes de toujours passer en priorité.

createRoot() — la porte d'entrée du concurrent mode

Sans createRoot(), aucune des features décrites dans cet article n'est active. C'est la première ligne à changer lors d'une migration React 17 → 18.

// Avant — React 17 et legacy mode
import ReactDOM from 'react-dom';
ReactDOM.render(<App />, document.getElementById('root'));

// Après — React 18 concurrent activé
import { createRoot } from 'react-dom/client';
const container = document.getElementById('root')!;
const root = createRoot(container);
root.render(<App />);

// Pour hydrater une page SSR
import { hydrateRoot } from 'react-dom/client';
hydrateRoot(container, <App />);

Ce que la migration active immédiatement

  • Automatic batching partout — y compris dans setTimeout, fetch, listeners natifs.
  • Suspense au niveau composant — accessible aux libs de data fetching compatibles.
  • useTransition / useDeferredValue / useId — disponibles dans le code.
  • Strict Mode renforcé — détecte les effets non idempotents et les rendus instables.
Migration en pratique : sur une codebase normale (sans usage de componentWillMount ou d'antipatterns deprecated), la migration ne casse rien. Le seul risque : du code qui dépendait du fait que setState dans un setTimeout déclenchait un rendu immédiat — ce qui est devenu rare et le plus souvent un bug latent.

useTransition — séparer urgent et non urgent

useTransition retourne un tuple [isPending, startTransition]. Tout setState appelé à l'intérieur de startTransition(() => ...) est marqué transition — priorité basse, interruptible. isPending bascule à true tant que le rendu de la transition n'est pas terminé.

Exemple — recherche dans une grande liste

import { useState, useTransition } from 'react';

export function SearchableList({ items }: { items: Item[] }) {
  const [query, setQuery] = useState('');
  const [filtered, setFiltered] = useState(items);
  const [isPending, startTransition] = useTransition();

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    const value = e.target.value;
    setQuery(value);                       // URGENT — l'input reste fluide

    startTransition(() => {
      // NON URGENT — peut être interrompu si l'utilisateur tape encore
      const next = items.filter(i =>
        i.name.toLowerCase().includes(value.toLowerCase()),
      );
      setFiltered(next);
    });
  }

  return (
    <>
      <input value={query} onChange={handleChange} placeholder="Rechercher…" />
      {isPending && <span aria-live="polite">Mise à jour…</span>}
      <ul style={{ opacity: isPending ? 0.6 : 1 }}>
        {filtered.map(i => <li key={i.id}>{i.name}</li>)}
      </ul>
    </>
  );
}

startTransition sans le hook

Une variante autonome startTransition est exportée depuis react. Elle s'utilise hors d'un composant (par exemple dans un store Zustand ou un callback dans un service worker) — vous perdez juste le flag isPending qui n'a de sens que dans un rendu.

import { startTransition } from 'react';
import { useNavigate } from 'react-router-dom';

function NavLink({ to, children }) {
  const navigate = useNavigate();

  return (
    <a
      href={to}
      onClick={e => {
        e.preventDefault();
        // Le routing peut prendre du temps si la nouvelle page suspend
        startTransition(() => navigate(to));
      }}
    >{children}</a>
  );
}
Bonne pratique : n'utilisez useTransition que pour les rendus visiblement coûteux (filtre, tri, layout complexe). Sur une mise à jour d'état trivial (toggle, compteur), il ajoute du bruit sans bénéfice mesurable.

L'usage de isPending mérite quelques précautions. Afficher un spinner trop visible pendant les transitions ruine l'effet recherché — l'utilisateur perçoit chaque frappe comme un chargement, ce qui est plus pesant que la latence d'origine. Préférez un état visuel subtil : opacity: 0.6 sur la liste, un curseur d'attente discret, ou un dégradé en arrière-plan. Le but est de signaler sans distraire.

useDeferredValue — déférer une valeur existante

useDeferredValue(value) renvoie une version « différée » de value. Quand value change rapidement, la version différée se met à jour quand React a du temps libre — typiquement après que le rendu prioritaire (l'input qui contient la valeur) soit terminé.

import { useState, useDeferredValue } from 'react';

export function HighlightedSearch() {
  const [text, setText] = useState('');
  // version différée — peut être en retard d'un cycle si l'input change vite
  const deferredText = useDeferredValue(text);

  const isStale = text !== deferredText;

  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <Results query={deferredText} style={{ opacity: isStale ? 0.5 : 1 }} />
    </>
  );
}

useTransition vs useDeferredValue — quand utiliser lequel ?

  • useTransition — vous écrivez le code qui déclenche la mise à jour. Vous pouvez marquer ce setState comme transition.
  • useDeferredValue — vous recevez une prop ou une valeur produite ailleurs (contexte, store). Vous ne contrôlez pas le setState source. Vous différez la valeur pour adoucir l'impact sur les composants lents.

Les deux peuvent se combiner. Pattern courant : un composant racine wrappe ses handlers dans startTransition, et un composant lent intermédiaire applique useDeferredValue sur la prop qui descend pour atténuer encore.

Automatic Batching — l'optimisation invisible

Avant React 18, le batching (regroupement des setState en un seul rendu) ne fonctionnait que dans les event handlers React. Trois setState dans un setTimeout = trois rendus distincts. Avec createRoot(), React 18 batch partout : setTimeout, fetch, promises, event listeners natifs, websocket onmessage.

// React 17 — 3 rendus (un par setState)
setTimeout(() => {
  setCount(c => c + 1);   // rendu 1
  setName('Alice');        // rendu 2
  setIsOpen(true);         // rendu 3
}, 100);

// React 18 avec createRoot — 1 seul rendu
setTimeout(() => {
  setCount(c => c + 1);
  setName('Alice');
  setIsOpen(true);
  // Tous regroupés automatiquement en 1 rendu après le callback
}, 100);

Quand vous voulez forcer la synchronie — flushSync

Très occasionnellement, vous avez besoin qu'un setState soit reflété immédiatement dans le DOM avant la ligne suivante de code — par exemple pour mesurer une dimension après mise à jour, ou pour scroller à une position juste rendue. flushSync sort une mise à jour du batching.

import { flushSync } from 'react-dom';

function handleAdd(newItem) {
  flushSync(() => {
    setItems(prev => [...prev, newItem]); // rendu immédiat
  });
  // À ce point, le DOM est à jour — on peut mesurer
  listRef.current.scrollTop = listRef.current.scrollHeight;
}
Conseil : n'utilisez flushSync que quand vous avez vraiment besoin du DOM mis à jour synchronement. Chaque appel casse une optimisation et peut introduire du jank. Dans 95 % des cas, un useEffect ou useLayoutEffect qui s'exécute après le rendu suffit.

Suspense pour le data fetching

Depuis React 16.6, Suspense permettait de gérer le lazy loading de composants. React 18 étend ce mécanisme au data fetching : une lib compatible (React Query v5+, Relay, RSC) peut faire « suspendre » un composant en lançant une Promise, et le Suspense parent affiche son fallback automatiquement.

import { Suspense } from 'react';
import { useQuery } from '@tanstack/react-query';

function UserProfile({ id }) {
  // useSuspenseQuery active le pattern Suspense — le composant se suspend
  // tant que la requête n'est pas résolue
  const { data: user } = useSuspenseQuery({
    queryKey: ['user', id],
    queryFn: () => fetch(`/api/users/${id}`).then(r => r.json()),
  });

  return (
    <article>
      <h2>{user.name}</h2>
      <p>{user.bio}</p>
    </article>
  );
}

export function ProfilePage({ id }) {
  return (
    <Suspense fallback={<ProfileSkeleton />}>
      <UserProfile id={id} />
    </Suspense>
  );
}

Suspense imbriqué — granularité progressive

function ProductPage({ id }) {
  return (
    <>
      <Suspense fallback={<HeroSkeleton />}>
        <ProductHero id={id} />
      </Suspense>

      <Suspense fallback={<ReviewsSkeleton />}>
        <ProductReviews id={id} />
      </Suspense>

      <Suspense fallback={<RelatedSkeleton />}>
        <RelatedProducts id={id} />
      </Suspense>
    </>
  );
}

Chaque Suspense isole un sous-arbre. Le hero apparaît dès que ses données sont prêtes (rapide), pendant que les reviews et related continuent de charger en background avec leurs skeletons. C'est ainsi que fonctionnent les pages produit modernes — Amazon, Shopify, Netflix — qui affichent quelque chose en moins d'une seconde même quand certaines parties prennent 3-4 secondes à arriver.

SSR streaming avec renderToPipeableStream

Le streaming SSR est le pendant serveur du Suspense client. Au lieu de générer la totalité du HTML avant de l'envoyer (ancien renderToString), le serveur envoie le HTML par fragments dès qu'ils sont prêts. La page apparaît visuellement plus tôt, l'utilisateur voit le shell et le contenu critique avant que les sections lentes ne soient résolues.

// server.js — Express + React 18 streaming SSR
import express from 'express';
import { renderToPipeableStream } from 'react-dom/server';
import { App } from './App';

const app = express();

app.get('/', (req, res) => {
  let didError = false;
  const stream = renderToPipeableStream(<App />, {
    bootstrapScripts: ['/static/client.js'],
    onShellReady() {
      // Le shell HTML est prêt — on commence à streamer
      res.statusCode = didError ? 500 : 200;
      res.setHeader('Content-Type', 'text/html');
      stream.pipe(res);
    },
    onShellError(err) {
      // Erreur dans le shell — fallback HTML statique
      res.statusCode = 500;
      res.send('<!DOCTYPE html><p>Erreur serveur</p>');
    },
    onError(err) {
      didError = true;
      console.error(err);
    },
  });
});

app.listen(3000);

Côté client, l'hydration progressive (hydrateRoot) prend le relais : React attache les event handlers sur les parties du DOM qui correspondent aux composants déjà rendus, sans attendre que la page entière soit livrée. C'est la base de l'App Router de Next.js et des React Server Components.

Quand le streaming SSR fait une vraie différence

  • Pages avec des sections lentes (recommandations basées IA, API tierces, agrégation de données).
  • Réseau lent (3G mobile) — l'utilisateur voit quelque chose en 200 ms au lieu de 2-3 secondes.
  • Apps multi-tenant où certaines parties prennent du temps spécifiquement par utilisateur.

Mesurer l'impact — INP et Web Vitals

Sans mesure, vous ne saurez pas si vos transitions améliorent vraiment l'UX. La métrique à suivre est l'INP (Interaction to Next Paint), introduite par Google en 2024 comme remplaçante du FID. INP mesure la latence entre l'interaction utilisateur (clic, frappe) et le prochain frame visible — exactement ce que le rendu concurrent optimise.

// Mesure côté client avec la lib web-vitals
import { onINP, onLCP, onCLS, onFCP } from 'web-vitals';

onINP(({ value, rating }) => {
  // value en ms, rating: 'good' | 'needs-improvement' | 'poor'
  console.log('INP:', value, rating);
  // Envoyer à votre analytics (Plausible, Vercel Analytics, Datadog…)
  navigator.sendBeacon('/api/metrics', JSON.stringify({
    metric: 'INP', value, rating, url: location.pathname,
  }));
});

// Seuils Google (2026) :
//   INP < 200 ms = Good
//   INP 200-500 ms = Needs Improvement
//   INP > 500 ms = Poor

Profiler React DevTools — la vue détaillée

L'extension React DevTools possède un onglet Profiler. Démarrez un enregistrement, faites l'interaction problématique (frappe rapide, clic sur un filtre lourd), arrêtez. Chaque barre représente un commit. Les transitions apparaissent en couleur différente — vous voyez instantanément si elles sont bien isolées du rendu prioritaire.

Comparez deux enregistrements avant/après l'introduction de useTransition. Sur le cas typique d'une recherche dans 10 000 items, on observe régulièrement un passage de 280 ms à 18 ms d'INP — une amélioration d'un ordre de grandeur sans aucun changement algorithmique.

Pièges classiques et anti-patterns

  • Wrapper toutes les mises à jour dans startTransition. Un input qui se met à jour via transition devient laggy au lieu de fluide. Réservez les transitions aux rendus visiblement coûteux.
  • Oublier d'activer createRoot(). Si vous gardez ReactDOM.render, useTransition et compagnie fonctionnent en mode dégradé sans le bénéfice du concurrent rendering.
  • Imbriquer trop de Suspense. Un boundary par section principale suffit ; multiplier les fallbacks rend la page « clignotante » et accentue le perceived lag.
  • flushSync abusif. Chaque appel casse l'automatic batching et le concurrent mode. À utiliser uniquement quand vous mesurez le DOM immédiatement après.
  • Tester uniquement sur machine de dev. Le rendu concurrent brille sur du matériel modeste (mobile bas de gamme, CPU throttled). Activez le « 6x slowdown » du throttling Chrome DevTools pour reproduire l'expérience réelle.
  • Penser que concurrent = parallèle. Tout reste sur le main thread. React découpe son travail en fines tranches mais n'utilise pas de workers. Un calcul JS lourd dans un render reste lourd — déplacez-le dans un Web Worker si besoin.
  • Mauvais découpage useTransition vs useDeferredValue. Comme vu en section 6 : useTransition pour vos handlers, useDeferredValue pour les valeurs reçues d'ailleurs.

Anti-pattern — le rendu coûteux mal isolé

// ❌ Tout est dans un seul gros composant — pas de granularité possible
function Dashboard() {
  const [filter, setFilter] = useState('');
  return (
    <>
      <input value={filter} onChange={e => setFilter(e.target.value)} />
      {expensiveAnalysis(items, filter)}
      {expensiveChart(data, filter)}
      {expensiveTable(data, filter)}
    </>
  );
}

// ✅ Isolation — chaque section peut être différée indépendamment
function Dashboard() {
  const [filter, setFilter] = useState('');
  const deferredFilter = useDeferredValue(filter);
  return (
    <>
      <input value={filter} onChange={e => setFilter(e.target.value)} />
      <Suspense fallback={<Skeleton />}>
        <Analysis filter={deferredFilter} />
      </Suspense>
      <Suspense fallback={<Skeleton />}>
        <Chart filter={deferredFilter} />
      </Suspense>
      <Suspense fallback={<Skeleton />}>
        <Table filter={deferredFilter} />
      </Suspense>
    </>
  );
}

React 19+ et la suite

React 19 (sorti fin 2024) construit directement sur le concurrent mode. Les nouveautés majeures s'inscrivent dans la même lignée et n'auraient pas été possibles sans l'architecture posée par React 18.

Server Components stables

Les React Server Components (RSC), introduits en alpha en 2023, sont passés stables en 2024. Ils s'appuient sur Suspense + streaming SSR. Côté Next.js App Router, c'est la valeur par défaut : les composants async await fetch() sont rendus sur le serveur et streamés vers le client.

Actions et useFormStatus

Les Actions simplifient les mutations (formulaires, POST). Une action s'enregistre via <form action={myAction}> ; le hook useFormStatus renvoie l'état pending automatiquement, sans useState manuel. C'est l'évolution naturelle de useTransition pour les opérations serveur.

React Compiler (alpha)

Le React Compiler analyse votre code et insère automatiquement les useMemo et useCallback nécessaires. Combiné au concurrent rendering, il réduit drastiquement le boilerplate de mémoïsation. Encore en alpha en 2026, à surveiller mais pas encore prêt pour la production sur la plupart des projets.

use() hook

// Le hook use() consomme une Promise ou un Context directement
import { use, Suspense } from 'react';

function Comments({ commentsPromise }) {
  const comments = use(commentsPromise); // suspend si non résolu
  return <ul>{comments.map(c => <li key={c.id}>{c.text}</li>)}</ul>;
}

function Page() {
  const promise = fetchComments(); // pas await ici !
  return (
    <Suspense fallback={<p>Chargement…</p>}>
      <Comments commentsPromise={promise} />
    </Suspense>
  );
}

use() remplace les patterns ad-hoc qui simulaient le « suspending sur Promise » avec des hacks (throw promise). C'est la primitive officielle de React 19 pour consommer des données asynchrones de manière concurrent-safe.

Mini-projet appliqué — dashboard fluide avec useTransition + Suspense

Cas réel : un dashboard analytics avec filtre de date, recherche full-text dans 5 000 commandes, et 4 widgets graphiques. Avant React 18, le filtre figeait l'UI pendant 600-900 ms à chaque frappe. Avec useTransition + Suspense data, l'input reste fluide à 60 fps, les widgets se mettent à jour en arrière-plan.

1. Filtre instantané + transition pour la liste lourde

function DashboardOrders() {
    const [search, setSearch] = useState('');
    const [filteredQuery, setFilteredQuery] = useState('');
    const [isPending, startTransition] = useTransition();

    function handleSearch(value: string) {
        setSearch(value);                            // urgent : feedback immédiat dans l'input
        startTransition(() => setFilteredQuery(value)); // non urgent : filtrage de 5000 lignes
    }

    return (
        <>
            <input
                value={search}
                onChange={(e) => handleSearch(e.target.value)}
                className={isPending ? 'opacity-60' : ''}
                placeholder="Rechercher une commande..."
            />
            <Suspense fallback={<OrdersListSkeleton />}>
                <OrdersList query={filteredQuery} />
            </Suspense>
        </>
    );
}

2. Suspense de data fetching avec use() (React 19)

Pour le détail des hooks et leur gestion, voir le guide des hooks React.

// Cache de requêtes mémoïsées par clé
const cache = new Map<string, Promise<Order[]>>();

function fetchOrdersOnce(query: string): Promise<Order[]> {
    if (!cache.has(query)) {
        cache.set(query, fetch(`/api/orders?q=${query}`).then(r => r.json()));
    }
    return cache.get(query)!;
}

function OrdersList({ query }: { query: string }) {
    // use() suspend automatiquement jusqu'à résolution de la Promise
    const orders = use(fetchOrdersOnce(query));

    return (
        <ul>
            {orders.map(o => <li key={o.id}>{o.reference} — {o.total}€</li>)}
        </ul>
    );
}

3. Widgets indépendants avec Suspense boundaries séparées

function Dashboard({ query }: { query: string }) {
    return (
        <div className="grid grid-cols-2 gap-4">
            <Suspense fallback={<ChartSkeleton />}>
                <RevenueChart query={query} />
            </Suspense>
            <Suspense fallback={<ChartSkeleton />}>
                <TopProductsChart query={query} />
            </Suspense>
            <Suspense fallback={<ChartSkeleton />}>
                <CustomersMap query={query} />
            </Suspense>
            <Suspense fallback={<ChartSkeleton />}>
                <ConversionFunnel query={query} />
            </Suspense>
        </div>
    );
}
Métriques mesurées sur ce dashboard production : avant migration React 18, INP médian à 620 ms sur Pixel 5 (mauvais selon Core Web Vitals). Après migration + useTransition + Suspense par widget : INP médian 78 ms (excellent), p95 180 ms (bon). Aucun changement d'algorithme, juste les wrappers. ROI : 2 jours de dev pour gain SEO + UX visible immédiatement.

4. Mesure avant/après avec web-vitals

import { onINP, onLCP, onCLS } from 'web-vitals';

onINP(({ value, rating }) => {
    console.log(`INP: ${value} ms (${rating})`);
    analytics.send('web-vitals', { metric: 'INP', value, rating });
});
onLCP(({ value, rating }) => analytics.send('web-vitals', { metric: 'LCP', value, rating }));
onCLS(({ value, rating }) => analytics.send('web-vitals', { metric: 'CLS', value, rating }));

Pour pousser les optimisations plus loin, lire également le guide React.memo + useMemo + useCallback et TanStack Query pour le data fetching production qui complète Suspense avec un cache intelligent et l'invalidation automatique.

Conclusion

Le rendu concurrent de React 18 n'est pas un détail technique — c'est un changement de paradigme qui redéfinit ce qu'une application React peut faire sans dégrader la fluidité. Sur les écrans qui en ont vraiment besoin (recherches en temps réel, dashboards complexes, grandes listes, tableaux filtrables), useTransition et useDeferredValue divisent l'INP par 5 à 20 sans changer un seul algorithme. C'est l'optimisation à plus fort rendement sur une codebase React moderne.

La règle pratique tient en quatre mots : active concurrent, mesure, itère, mesure. Migrer vers createRoot() est gratuit. Ajouter useTransition sur les filtres et tris coûteux prend cinq minutes. La mesure d'avant/après sur l'INP montre instantanément si l'effort est rentable. La majorité des apps modernes l'adoptent en 2026 ; celles qui restent en React 17 traînent une dette technique qui leur ferme aussi la porte aux Server Components, à Next.js App Router, et à React Compiler.

Récapitulatif des bonnes pratiques :
  • Activer le concurrent mode via createRoot() — première migration obligatoire
  • Marquer les mises à jour coûteuses avec useTransition ou startTransition
  • Préférer useDeferredValue quand on ne contrôle pas le code source de la valeur
  • Profiter de l'automatic batching partout — pas besoin de regrouper manuellement
  • Utiliser flushSync uniquement quand on doit mesurer le DOM immédiatement après
  • Envelopper les sections de données dans <Suspense> avec des fallbacks pertinents
  • En SSR, utiliser renderToPipeableStream + hydrateRoot
  • Mesurer l'INP avec web-vitals et React DevTools Profiler avant/après
  • Tester sur du matériel modeste (Chrome 6x slowdown) pour valider l'amélioration réelle
  • Préparer la migration vers React 19 — Actions, use(), Server Components

Partager