Accueil / Articles

Les composants React doivent être purs : l’idée derrière StrictMode et le compilateur

Pourquoi React exige des composants purs, comment le double rendu de StrictMode révèle l’impureté, où se situent les effets secondaires, et pourquoi le compilateur React repose sur la pureté.

Éditeur : REACTAPP.TOP9 min de lecture2023 mots

Sources: React Components Must Be Pure — the Secret Behind StrictMode and the Compiler

Jusqu’à présent, l’approche React consistait à « l’écrire de cette manière et l’écran s’affiche ». Il existe cependant une règle que React attend silencieusement que chaque composant suive, et elle explique deux choses qui déroutent la plupart des nouveaux venus : pourquoi StrictMode appelle votre composant deux fois, et comment le compilateur React peut optimiser le code de manière si audacieuse. Cette règle, c’est la pureté.

Qu’est-ce qu’une fonction pure ?

Rappelez-vous les mathématiques : y = f(x) = 2x renvoie 6 pour une entrée de 3. Cela se produit hier, aujourd’hui et lors de la centième appel, sans jamais modifier quoi que ce soit en dehors d’elle-même.

Une fonction de ce type est pure, ce qui signifie que deux conditions sont remplies.

  1. La même entrée donne toujours la même sortie.
  2. Elle n’a aucun effet secondaire, donc rien en dehors de son propre champ d’action n’est modifié.

Le code rend cela plus clair. double ci-dessous est pur, tandis que addToTotal ne l’est pas.

// ✅ pure function — same input, same output, and it touches nothing outside
function double(x: number): number {
  return x * 2;
}
double(3); // 6
double(3); // 6 — six no matter how many times you call it

// 🔴 impure function — it changes the outer variable `total` (a side effect)
let total = 0;
function addToTotal(x: number): number {
  total += x;     // side effect!
  return total;   // the result changes on every call
}
addToTotal(3); // 3
addToTotal(3); // 6 — same input, different result

Les composants doivent également être purs

React suppose que chaque composant est une fonction pure. Avec les mêmes props et état, il doit toujours retourner le même JSX.

type Props = { name: string };

// ✅ pure — same name, always the same result
function Greeting({ name }: Props) {
  return <h1>Hello, {name}!</h1>;
}

En donnant à ce composant le nom « Jane », on obtient toujours <h1>Hello, Jane!</h1>. React compte sur cette prévisibilité pour mettre à jour l’écran de manière efficace.

Enfreindre la règle : modifier l’extérieur pendant le rendu

Les problèmes commencent lorsque un composant modifie quelque chose en dehors de lui-même pendant son rendu.

let count = 0; // a variable outside the component

// 🔴 not pure — changes an external variable while rendering
function Counter() {
  count = count + 1; // side effect!
  return <p>{count}</p>;
}

Counter augmente la variable externe count à chaque rendu, de sorte que son affichage dépend de la fréquence à laquelle il est appelé : 1 la première fois, 2 la deuxième. Les deux conditions de pureté ne sont pas remplies. React peut rendre un composant plusieurs fois ou dans un ordre différent, ce qui entraîne des bugs difficiles à prédire chez un composant de ce type.

Essayez et voyez pourquoi vous obtenez 2

Lorsque vous exécutez ce Counter, ce qui apparaît en premier à l’écran est 2, et non 1, et cette valeur ne augmente pas par la suite. Cela peut sembler énigmatique au début, mais le comprendre constitue une excellente leçon sur la pureté.

Pourquoi 2 ? En mode développement, StrictMode appelle la fonction du composant deux fois lors du montage, ce qui fait que le premier montage exécute Counter deux fois.

  • Première appel : count passe de 0 à 1 et le résultat est <p>1</p>.
  • Deuxième appel : count passe de 1 à 2 et le résultat est <p>2</p>.

L’écran affiche le deuxième résultat. Lorsque StrictMode est désactivé, la fonction s’exécute une seule fois et on verrait 1.

Pourquoi s’arrête-t-elle à 2 au lieu de continuer à 3, 4, etc. ? Un composant ne s’exécute que lorsque React le rérendu à nouveau. Celui-ci n’a pas d’état et son parent ne le rérendu pas non plus, donc il s’exécute lors du montage initial (deux fois, grâce à StrictMode) puis reste inactif, laissant count bloqué.

Pour observer l’évolution de la valeur, forcez un rérendu. L’exemple suivant le fait en cliquant sur un bouton. (useState sera traité correctement plus tard, alors pour l’instant considérez-le simplement comme un outil qui réaffiche l’écran lorsque vous cliquez sur le bouton.)

import { useState } from 'react';

let count = 0;

function Counter() {
  const [, setTick] = useState(0); // a device to trigger re-renders
  count = count + 1; // 🔴 changes an external variable on every render
  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setTick((n) => n + 1)}>Re-render</button>
    </div>
  );
}

Chaque clic rérendu le composant Counter en fonction de son propre changement d’état, et StrictMode le fait appeler deux fois à chaque fois, ce qui fait que le nombre augmente de deux : 2, 4, 6, et ainsi de suite. Sans StrictMode, il augmenterait d’un seul cran à la fois.

Une précaution concernant cette démonstration : pourquoi l’état est-il placé à l’intérieur de Counter ? Si vous le déplaciez vers le composant parent et laissiez Counter en tant que composant enfant sans props, appuyer sur le bouton ne changerait pas le 2 affiché. Un composant qui ne reçoit aucune entrée est un composant dont le résultat ne peut jamais varier, c’est pourquoi le compilateur React le met en cache et évite sa rérendu. Pour observer ce phénomène, il faut modifier la variable externe à l’intérieur du composant qui effectivement se rérendu. Curieusement, ce comportement illustre bien le fait que le compilateur suppose une pureté des composants. La manière dont le compilateur met en cache les composants mérite une discussion à part entière.

Voici le point essentiel. Un composant pur doit produire le même résultat quel que soit le nombre de fois où il est appelé, mais celui-ci dépend du nombre d’appels. StrictMode l’appelle délibérément deux fois afin de vous montrer cette impureté.

La solution consiste à ne pas toucher à l’extérieur. Lors du rendu, calculez le résultat uniquement à partir de vos propres props et état.

// ✅ pure — computed from input (props) alone
function Counter({ count }: { count: number }) {
  return <p>{count}</p>;
}

Vous pouvez modifier ce que vous avez créé pendant le rendu

La pureté ne signifie pas ne rien changer. Elle signifie ne pas toucher aux éléments qui existaient avant le début du rendu, tels que les variables externes, les props et l’état. Les variables ou les tableaux que vous créez directement pendant le rendu vous appartiennent et peuvent être modifiés. Il s’agit simplement de calculs, et non d’effets secondaires.

function ProductList({ products }: { products: Product[] }) {
  // ✅ this array was just created in this render — handle it however you like
  const sorted = [...products].sort((a, b) => a.price - b.price);
  return (
    <ul>
      {sorted.map((p) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  );
}

Ce qui compte, c’est la copie créée avec [...products]. Trier le tableau d’origine products, qui est transmis en tant que props, modifierait ces derniers et enfreindrait la règle. La copie a été générée lors de cette rendu, donc tout est en ordre.

Premier mystère : pourquoi StrictMode exécute les choses deux fois

Pendant le développement, StrictMode appelle délibérément les fonctions de composant deux fois afin de détecter les composants qui ne sont pas purs. Il s’agit du même enveloppement <StrictMode> placé autour de l’application lors de la création du projet.

// main.tsx — in development, the components inside this render twice
createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

Un composant pur produit des résultats identiques lors des deux exécutions, donc rien ne se passe. Un composant comme Counter ci-dessus, qui accède à une variable externe, montre immédiatement des signes d’alerte : la sortie varie ou une valeur double. StrictMode met en évidence ces symptômes pendant que vous développez, bien avant qu’ils ne deviennent des problèmes difficiles à résoudre une fois l’application en ligne.

Ainsi, cette double exécution n’est pas une erreur. C’est une vérification de bon fonctionnement. Si l’exécution deux fois ne cause aucun problème, le composant est pur. Dans une version de production, il s’exécute une seule fois.

Où les effets secondaires ont leur place

Vous vous poserez une question légitime : le journalage, l’envoi de données vers un serveur et le démarrage d’un chronomètre sont clairement nécessaires, alors où les placer ? Partout sauf dans le processus de rendu lui-même.

  • La réaction aux actions de l’utilisateur, telles que les clics et la saisie, doit se faire dans les gestionnaires d’événements.
  • La synchronisation avec un système externe après que l’écran ait été dessiné devrait être gérée par les effets.

Imaginons que vous enregistriez une consultation de page directement dans le corps de rendu : cela se déclenchera à chaque rendu, et son nombre doublera en mode StrictMode pendant le développement. Déplacez cette appelée vers un gestionnaire d’événements afin qu’elle ne s’exécute que lorsque quelqu’un clique réellement.

// 🔴 side effect during rendering — fires on every render
function ProductPage({ id }: { id: number }) {
  logView(id); // a side effect in the render body — not allowed
  return <h1>Product {id}</h1>;
}

// ✅ in an event handler — runs only at the moment the user clicks
function BuyButton({ id }: { id: number }) {
  return <button onClick={() => logPurchase(id)}>Buy</button>;
}

Gardez le corps de rendu pur et placez les effets secondaires à leur emplacement approprié. Ce principe revient constamment.

Deuxième mystère : pourquoi le compilateur dépend de la pureté

Si vous avez activé le React Compiler, vous vous souvenez probablement qu’il prend en charge les fonctions useMemo et useCallback écrites manuellement. C’est la pureté qui rend cela possible.

Le compilateur fonctionne en partant du principe que les composants sont purs. Étant donné qu’une même entrée produit toujours la même sortie, il est considéré comme sûr de réutiliser le résultat précédent lorsque les entrées n’ont pas changé, et il insère automatiquement la mémoisation. Si un composant modifie son état pendant le rendu, la réutilisation pourrait produire des résultats erronés ; c’est pourquoi le compilateur saute l’optimisation pour les composants qui enfreignent cette règle. La règle ESLint activée en même temps que le compilateur vous indique quel composant est en cause.

// 🔴 not pure → the compiler skips optimization (ESLint points at this component)
let renderCount = 0;
function RenderCounter() {
  renderCount++; // mutating an external variable during render — a violation
  return <p>{renderCount}</p>;
}

// ✅ pure → the compiler memoizes automatically (reuse the previous result when inputs match)
function Label({ text }: { text: string }) {
  return <p>{text}</p>;
}

function App() {
  return (
    <>
      <RenderCounter />
      <Label text="Pure Component" />
    </>
  );
}

export default App;

Un composant écrit de manière pure apparaît dans les DevTools avec l’icône « Memo », même si aucune instruction useMemo n’a été écrite.

En bref, la pureté est la condition préalable pour que le compilateur puisse vous aider. Écrivez des composants purs et les améliorations de performance suivront automatiquement. La seule chose à prendre en compte, c’est d’écrire du code pur.

Voir l’interface utilisateur comme un arbre

Une autre perspective vient compléter cela. Les composants contiennent d’autres composants : App contient ProductList, qui à son tour contient plusieurs ProductCard. Ce niveau de nesting forme naturellement un arbre.

function App() {
  return <ProductList products={products} />;
}

function ProductList({ products }: { products: Product[] }) {
  return (
    <ul>
      {products.map((p) => (
        <ProductCard key={p.id} product={p} />
      ))}
    </ul>
  );
}

À mesure que les composants s’empilent, ce code génère l’arbre suivant.

App
└─ ProductList
   ├─ ProductCard
   ├─ ProductCard
   └─ ProductCard

La vue en arbre devient de plus en plus importante à mesure que vous avancez. Les données sous forme de props s’y déplacent de haut en bas, et pour l’état dont vous apprendrez plus tard, décider de l’endroit où le placer dans l’arbre devient le cœur de votre conception. Gardez à l’esprit l’image « l’écran équivaut à l’arbre des composants » et même des interfaces utilisateur complexes peuvent être analysées structuralement.

Résumé

  • React traite chaque composant comme une fonction pure : mêmes props et état, même JSX.
  • Lors du rendu, ne modifiez rien d’externe, y compris les variables, les props et l’état. Les valeurs créées lors du rendu actuel peuvent être modifiées, par exemple lors du tri d’une copie.
  • Les deux exécutions de StrictMode en mode développement constituent une vérification de bon fonctionnement qui met en évidence les composants impurs. Un composant pur passe sans modification.
  • Placez les effets secondaires dans des gestionnaires d’événements ou des Effects, jamais dans le corps du rendu.
  • La pureté permet au compilateur React d’optimiser automatiquement, donc écrire du code pur vous assure de meilleures performances.
  • L’interface utilisateur est un arbre de composants, et les données s’y propagent.

Jusqu’à présent, l’écran n’est qu’une image figée : vous pouvez le construire à partir de composants, écrire du markup avec JSX, transmettre des données via des props et créer des vues dynamiques à l’aide de conditions et de boucles, mais appuyer sur un bouton ne fait rien. L’étape suivante consiste à faire en sorte que l’écran réagisse à l’utilisateur, et cela commence par la gestion des événements.

Les pages de documentation officielle de React sur le maintien de la pureté des composants et sur la compréhension de l’interface utilisateur en tant qu’arbre abordent le même sujet en plus de détail.