React Compiler : adieu useMemo et useCallback manuels ?

1. Vue d’ensemble

Beaucoup de projets React ont des appels à useMemo, useCallback et React.memo éparpillés partout dans la codebase. L’objectif est d’éviter les re-rendus inutiles, mais le coût est un code plus long et source d’erreurs dans les dépendances. React Compiler déplace cette mémorisation au moment du build : il analyse les composants et ajoute automatiquement du cache où c’est nécessaire.

Cet article explique ce qu’est React Compiler et pourquoi il suscite de l’intérêt. Vous découvrirez comment l’activer dans un projet Vite, comment migrer progressivement, comment tester le comportement de mémorisation, et les cas où vous devriez l’éviter.

2. Le problème avec la mémorisation manuelle

Considérons une page de liste de commandes avec un champ de filtre et un compteur sans relation :

import { useState, useMemo, useCallback, memo } from "react";

type Order = { id: number; customer: string; total: number };

const OrderList = memo(function OrderList({
  orders,
  onSelect,
}: {
  orders: Order[];
  onSelect: (id: number) => void;
}) {
  return (
    <ul>
      {orders.map((o) => (
        <li key={o.id} onClick={() => onSelect(o.id)}>
          {o.customer}: {o.total}
        </li>
      ))}
    </ul>
  );
});

export function OrdersPage({ orders }: { orders: Order[] }) {
  const [keyword, setKeyword] = useState("");
  const [clicks, setClicks] = useState(0);

  const filtered = useMemo(
    () => orders.filter((o) => o.customer.includes(keyword)),
    [orders, keyword]
  );

  const handleSelect = useCallback((id: number) => {
    console.log("Selected order", id);
  }, []);

  return (
    <>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      <button onClick={() => setClicks((c) => c + 1)}>Clicks: {clicks}</button>
      <OrderList orders={filtered} onSelect={handleSelect} />
    </>
  );
}

Le code fonctionne, mais il présente trois points faibles :

  • Si vous supprimez memo sur OrderList, les appels à useMemo et useCallback deviennent quasi inutiles.
  • Si vous oubliez keyword dans le tableau de dépendances, la liste filtrée affichera des données obsolètes.
  • Dès qu’une prop devient un littéral objet comme style={{ color: "red" }}, tout l’effort de mémorisation s’effondre.

La mémorisation manuelle est un système implicite. Quiconque modifie le composant doit le comprendre, et les bugs ne se manifestent généralement que lors d’un audit de performance.

3. Qu’est-ce que React Compiler

React Compiler est un outil qui s’exécute au build, généralement sous forme de plugin Babel. Il lit le code des composants et des hooks, comprend le flux des données, puis génère du code avec cache pour :

  • Les valeurs calculées : équivalent de useMemo.
  • Les fonctions : équivalent de useCallback.
  • Les éléments JSX : équivalent d’envelopper les enfants avec memo. Si l’entrée d’un élément ne change pas, le compilateur réutilise l’ancien élément et React ignore le re-rendu du sous-arbre.

Le code ci-dessous illustre l’idée du code après compilation. C’est une version simplifiée, pas la sortie réelle :

function OrdersPage({ orders }) {
  const $ = useMemoCache(6); // tableau de cache attaché à l'instance du composant
  const [keyword, setKeyword] = useState("");

  let filtered;
  if ($[0] !== orders || $[1] !== keyword) {
    filtered = orders.filter((o) => o.customer.includes(keyword));
    $[0] = orders;
    $[1] = keyword;
    $[2] = filtered;
  } else {
    filtered = $[2];
  }
  // ... similaire pour handleSelect et les éléments JSX
}

Le compilateur compare chaque entrée avec sa valeur du render précédent. Il recalcule uniquement si une entrée change. Par rapport à la mémorisation manuelle, il y a deux différences importantes :

  1. Plus de granularité : le compilateur peut mémoriser des portions de code après des instructions conditionnelles, ce que useMemo ne peut pas faire puisque les hooks ne peuvent pas être appelés conditionnellement.
  2. Pas de tableau de dépendances écrit à la main : les dépendances sont déduites du code, donc impossible d’en oublier une.

4. Pourquoi React Compiler suscite de l’intérêt

Quelques raisons expliquent pourquoi ce sujet est très mentionné :

  • Moins de code boilerplate : le code revient à du “React pur”, plus facile à lire et à reviewer.
  • Meilleure performance par défaut : les équipes n’ont pas besoin d’experts en profiling pour éviter les re-rendus inutiles.
  • Respect forcé des Rules of React : le compilateur n’optimise que le code “correct”. Il s’accompagne d’un ensemble de règles lint qui détectent les violations, même si vous n’avez pas encore activé le compilateur.
  • Migration progressive : vous pouvez l’activer pour un répertoire ou un composant, sans réécrire toute l’application.

Note : Le compilateur ne rend pas le code plus rapide partout. Il réduit les rendus répétés. Si votre goulot d’étranglement est le réseau, la taille du bundle ou une boucle lourde qui s’exécute à chaque changement d’entrée, le compilateur ne le résoudra pas.

5. Activer React Compiler dans un projet

5.1. Installation avec Vite

Installez le plugin et l’ensemble de règles lint. Le nom du package peut changer avec le temps, donc vérifiez la documentation officielle de React pour votre version.

npm install -D babel-plugin-react-compiler eslint-plugin-react-hooks

Ajoutez le plugin à la configuration Vite :

// vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [
    react({
      babel: {
        plugins: [["babel-plugin-react-compiler", {}]],
      },
    }),
  ],
});

Le compilateur doit s’exécuter en premier dans la chaîne des plugins Babel, car il a besoin de lire le code original avant que d’autres plugins ne le transforment.

Si votre projet utilise une version plus ancienne de React, le compilateur a besoin d’un package runtime supplémentaire et de l’option target pour générer du code compatible. Cela dépend de la version, consultez la documentation avant de configurer.

Avec des frameworks comme Next.js ou Expo, il y a généralement des options de configuration spécifiques pour activer le compilateur. Le nom de l’option a changé entre les versions, donc lisez les notes de version du framework plutôt que de copier une ancienne configuration.

5.2. Vérifier que le compilateur fonctionne

Ouvrez React DevTools. Les composants optimisés par le compilateur auront une étiquette du type “Memo ✨” à côté du nom. Son absence signifie que le compilateur a ignoré le composant, généralement à cause d’une violation des règles de la section 6.

5.3. Activer le lint

// eslint.config.js
import reactHooks from "eslint-plugin-react-hooks";

export default [
  reactHooks.configs.recommended,
];

Le lint signale les portions de code que le compilateur ignorera ou qui pourraient mal fonctionner. Vous devriez activer le lint avant le compilateur et corriger tous les avertissements.

6. Rules of React : conditions pour que le compilateur fonctionne

Le compilateur suppose que les composants sont des fonctions pures lors du rendu. L’exemple suivant viole cela :

export function CartSummary({ items }: { items: { price: number }[] }) {
  items.sort((a, b) => b.price - a.price); // muter la prop !
  const total = items.reduce((sum, i) => sum + i.price, 0);
  return <p>Top item: {items[0]?.price}, total: {total}</p>;
}

items.sort modifie directement le tableau passé en entrée. Avec du cache, le compilateur peut ne pas remarquer que items a “changé” puisque la référence reste la même, et l’UI affichera des données incorrectes. Le lint avertira avec un message du type :

error  Mutating a value returned from a function whose return value should not be mutated  react-hooks/...

Le message exact dépend de la version du plugin. La correction est de créer une copie :

const sorted = [...items].sort((a, b) => b.price - a.price);

Les violations courantes incluent :

  • Muter des props, l’état ou une valeur retournée par un hook.
  • Lire ou écrire ref.current pendant le rendu.
  • Appeler des hooks conditionnellement ou dans une boucle.
  • Effectuer des effets de bord directement dans le corps du composant, par exemple écrire dans une variable module ou appeler une API.

Quand le compilateur rencontre tel code, il ignore généralement le composant au lieu de le casser. C’est un comportement sûr, mais vous perdrez l’optimisation.

7. Migration progressive dans un gros projet

7.1. Mode opt-in

Avec une grosse base de code, vous pouvez compiler uniquement les composants marqués :

// vite.config.js (extrait)
plugins: [["babel-plugin-react-compiler", { compilationMode: "annotation" }]],
export function InvoiceTable({ invoices }: { invoices: Invoice[] }) {
  "use memo";
  // ... ce composant est compilé
}

La directive "use memo" au début du corps de la fonction dit au compilateur de traiter ce composant. Les autres restent inchangés.

7.2. Exclure temporairement un composant

Si un composant cause une erreur avec le compilateur global, vous pouvez le désactiver juste pour lui :

export function LegacyChart(props: ChartProps) {
  "use no memo";
  // ... ancien code utilisant une librairie qui mute les données
}

Note : "use no memo" est une échappatoire temporaire. Vous devriez documenter la raison et créer une tâche pour corriger, ne pas en faire une pratique par défaut.

7.3. Et les useMemo existants ?

Le compilateur fonctionne avec du code contenant déjà useMemo/useCallback, donc vous n’avez pas besoin de les supprimer immédiatement. Une suppression en masse peut modifier les comportements subtils, particulièrement quand la valeur est utilisée comme dépendance d’un useEffect. Supprimez progressivement, avec des tests. Pour les nouveaux composants, écrivez du code pur et laissez le compilateur gérer.

8. Écrire des tests pour le comportement de mémorisation

Vous pouvez vérifier que les enfants ne re-renderont pas quand un état sans lien change. L’exemple ci-dessous utilise Vitest et React Testing Library, en exécutant avec la même configuration Vite qui active le compilateur :

npm install -D vitest @testing-library/react @testing-library/user-event jsdom
// OrdersPage.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, it, expect, vi } from "vitest";

const renderSpy = vi.fn();

function OrderList({ orders }: { orders: string[] }) {
  renderSpy();
  return <ul>{orders.map((o) => <li key={o}>{o}</li>)}</ul>;
}

function OrdersPage() {
  const [clicks, setClicks] = useState(0);
  const orders = ["Alice", "Bob"]; // pas de useMemo
  return (
    <>
      <button onClick={() => setClicks((c) => c + 1)}>Clicks: {clicks}</button>
      <OrderList orders={orders} />
    </>
  );
}

describe("OrdersPage avec React Compiler", () => {
  it("ne re-rend pas OrderList quand seul le compteur change", async () => {
    render(<OrdersPage />);
    await userEvent.click(screen.getByRole("button"));
    await userEvent.click(screen.getByRole("button"));

    expect(screen.getByRole("button")).toHaveTextContent("Clicks: 2");
    expect(renderSpy).toHaveBeenCalledTimes(1);
  });
});

Explications :

  • orders est un littéral de tableau, pas enveloppé dans useMemo. Sans compilateur, un nouveau tableau est créé à chaque rendu et OrderList re-rend.
  • Avec le compilateur, le tableau orders et l’élément <OrderList /> sont tous deux en cache car ils ne dépendent pas de clicks.
  • renderSpy est appelé une seule fois, au premier rendu.

Résultat attendu :

✓ OrdersPage avec React Compiler > ne re-rend pas OrderList quand seul le compteur change
Test Files  1 passed (1)

Sans le compilateur, l’assertion finale échouerait avec une valeur de 3. Le matcher toHaveTextContent nécessite @testing-library/jest-dom dans un fichier de configuration.

Note : Appeler un spy dans le corps du composant est un effet de bord. Cette approche est seulement acceptable en test pour mesurer le nombre de rendus. Ne l’utilisez pas en code production.

9. Quand NE PAS l’utiliser (ou être prudent)

  • Codebase viole beaucoup les Rules of React : le compilateur ignorera la plupart des composants, ou pire, révélera des bugs qui étaient masqués. Corrigez d’abord selon le lint.
  • Librairies basées sur la mutation : certaines librairies de gestion d’état ou de formulaire modifient des objets sur place et s’attendent à ce que les composants lisent la nouvelle valeur à chaque rendu. Pour les composants qui les utilisent, vous pouvez avoir besoin de "use no memo".
  • Pipeline de build spécifique : si vous n’utilisez pas Babel ou que votre toolchain ne supporte pas les plugins, l’intégration sera coûteuse. Vérifiez le support de votre bundler au préalable.
  • Application petite, peu interactive : les bénéfices peuvent être négligeables comparés au temps de build additionnel et à la complexité de debug.
  • Besoin de références stables pour les effets : le compilateur mémorise pour optimiser, pas pour garantir la sémantique. Si la logique dépend du fait qu’une valeur garde la même référence, par exemple pour éviter qu’un effet se réexécute, gardez useMemo/useCallback explicite pour montrer l’intention.

De plus, lors du debug, le code exécuté dans le navigateur diffère du code que vous avez écrit. Les source maps aident, mais vous devriez vous familiariser avec DevTools et les outils de playground du compilateur pour voir la sortie quand nécessaire.

10. Conclusion

React Compiler ne supprime pas useMemo et useCallback, mais les transforme d’outils du quotidien en échappatoires pour cas particuliers. La condition sine qua non est que le code respecte les Rules of React. C’est pourquoi la première étape est toujours d’activer le lint et de corriger les violations.

Progression suggérée : activez le lint, corrigez les avertissements, activez le compilateur en mode annotation pour quelques composants, écrivez des tests mesurant le nombre de rendus, puis développez progressivement. De cette façon, vous obtenez une meilleure performance sans besoin de tableaux de dépendances écrits à la main.

reactreact-compilerperformancefrontendtech-trends