Où useState conserve sa valeur : les éléments React versus les fibres
Pourquoi un composant fonctionnel React oublie tout entre les appels, pourquoi les éléments ne peuvent pas conserver d’état, et comment le champ memoizedState de la fibre maintient les valeurs de useState en vie.
Un composant fonctionnel n’est rien d’autre qu’une fonction, et les fonctions oublient leurs variables locales dès qu’elles retournent une valeur. Pourtant, useState restitue la valeur mise à jour à chaque rendu, comme si la fonction s’en souvenait. Cet article répond précisément à une question très ciblée : où se trouve physiquement cette valeur entre les rendus ? À la fin, vous serez capable de distinguer les deux objets que React crée pour chaque composant, l’élément et la fibre, et d’expliquer lequel contient l’état et pourquoi.
Le problème : une fonction sans mémoire
Commençons par le composant le plus familier qui existe, un compteur :
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
Cliquez une fois et cela affiche 1, cliquez à nouveau et cela affiche 2. Rien d’étonnant, jusqu’à ce que l’on examine le code en tant que JavaScript pur. Counter est une fonction. Chaque fois qu’une fonction est appelée, ses variables locales sont créées à partir de rien, et lorsqu’elle retourne, elles disparaissent. Un deuxième appel recommence tout depuis le début.
Selon cette logique, la deuxième fois que React appelle Counter(), la ligne useState(0) devrait à nouveau produire 0. Or, elle produit 1. Quelque chose en dehors de la fonction doit conserver le nombre entre deux appels. Le corps de la fonction ne peut pas être responsable, car ce n’est tout simplement pas ainsi que les fonctions fonctionnent. Alors, qu’est-ce que c’est ?
Élimination de l’élément
Il y a un candidat évident qui s’avère être faux, et son élimination rend la véritable réponse plus claire.
La ligne <button onClick={...}>{count}</button> est du JSX. Les navigateurs ne traitent jamais de JSX ; un compilateur tel que Babel ou le compilateur TypeScript le réécrit en une simple appel de fonction avant que le code ne soit exécuté. Conceptuellement, le résultat est :
React.createElement("button", { onClick: fn }, count)
Avec l’exécution automatique du JSX introduite dans React 17, l’appel compilé est en réalité jsx() provenant de react/jsx-runtime plutôt que React.createElement, mais les deux accomplissent la même fonction. Quelle que soit la fonction utilisée, elle renvoie un objet ordinaire :
{
type: "button",
props: { onClick: fn, children: 1 },
}
React qualifie cela d’élément. Il s’agit d’une description, et non d’une entité réelle : un petit enregistrement indiquant qu’un bouton doit apparaître ici, avec ce gestionnaire de clic, affichant ce nombre. Les véritables éléments comportent quelques champs supplémentaires, tels que key, ref et un marqueur interne $typeof, mais aucun d’eux ne stocke l’historique.
Voici maintenant l’observation cruciale. Chaque appel à Counter crée un élément entièrement nouveau. En cliquant sur le bouton, Counter() s’exécute, un nouvel objet est retourné, et l’ancien devient inutilisable. Si l’état était stocké dans l’élément, il ne serait pas possible de passer de la rendu deux au rendu un, car l’objet de rendu un n’existe plus lorsque le rendu deux commence.
Cette nature éphémère est intentionnelle. Les éléments sont bon marché précisément parce qu’ils sont reconstruits à partir de zéro à chaque rendu et n’ont jamais besoin d’être synchronisés avec quoi que ce soit. Cela signifie également qu’ils ne peuvent pas se trouver là où est stocké count.
L’objet qui survit : la fibre
En plus des éléments, React conserve un autre objet à durée de vie plus longue pour chaque composant monté. Il n’est pas reconstruit à chaque rendu. Il est créé lorsque le composant est monté pour la première fois, puis conservé et mis à jour tant que le composant reste à l’écran. C’est la fibre.
Les fibres sont souvent décrites comme quelque chose de mystérieux, mais au niveau le plus bas, une fibre est un objet JavaScript ordinaire. Il n’y a pas de construction spéciale en temps d’exécution derrière elle. Si vous arrêtez l’exécution dans un débogueur à l’intérieur du mécanisme de réconciliation de React et que vous inspectez une fibre, vous verrez un objet normal doté d’un ensemble de propriétés, du genre que l’on pourrait écrire soi-même avec des accolades. Vous pouvez également y jeter un coup d’œil depuis le navigateur : React associe des clés internes aux nœuds DOM qui pointent vers leurs fibres, c’est ainsi que React DevTools les trouve.
Au lieu de lister tous les champs en même temps, il est utile de construire la fibre de Counter un propriété à la fois. Immédiatement après le montage, une version minimale ressemble à ceci :
{
type: Counter,
}
type : à qui appartient cette comptabilité
type est le champ le plus simple. Il indique quel composant cette fibre suit. type: Counter signifie que l’objet existe pour gérer une instance de Counter, en conservant une référence à la fonction elle-même.
Les éléments hôtes disposent également de fibres. Le bouton affiché par Counter a sa propre fibre, et son type est la chaîne de caractères "button" plutôt qu’une fonction. Ainsi, la fibre <Counter /> a pour valeur { type: Counter } tandis que la fibre <button> a pour valeur { type: "button" } : même champ, même fonction, mais pointant soit vers votre composant, soit vers une balise intégrée.
type joue également un rôle dans la réconciliation. Lorsque React compare un nouvel élément à une fibre existante à la même position, un type correspondant lui permet de réutiliser cette fibre et son état, tandis qu’un type différent le pousse à jeter l’ancienne fibre et en créer une nouvelle. C’est pourquoi changer entre deux composants différents au même endroit réinitialise leur état.
Cependant, le type en soi ne dit rien sur la manière dont l’état persiste. Pour cela, il faut le champ suivant.
memoizedState : où réside réellement le compteur
useState a besoin d’un emplacement en dehors de la fonction pour conserver la valeur actuelle, car les variables locales de la fonction sont réinitialisées à chaque appel. Cet emplacement est un champ sur la fibre nommé memoizedState. « Memoisé » signifie simplement mémorisé : conservé de la fois précédente au lieu d’être recomputé.
Lorsqu’on l’ajoute au schéma, on obtient :
{
type: Counter,
memoizedState: { count: 0 },
}
Ce n’est qu’une représentation simplifiée. Dans la mise en œuvre réelle, memoizedState sur le fiber d’un composant fonctionnel fait référence au premier élément d’une liste chaînée d’objets hook, un pour chaque appel de hook, et le nombre 0 se trouve dans le champ memoizedState propre à ce hook plutôt que dans un objet { count: 0 }. React n’a aucune idée que votre variable s’appelle count ; il ne connaît que « la valeur du premier hook ». Pour comprendre où se trouve l’état, la version simplifiée suffit.
Suivez maintenant un clic. React appelle à nouveau Counter(). Lorsque l’exécution atteint useState(0), le hook ne renvoie pas la valeur 0 dans votre code. Il consulte l’état stocké de la fibre, trouve la valeur actuelle (qui est 1 une fois le clic traité) et la renvoie. L’argument de useState n’est qu’une valeur initiale : elle est utilisée lors du rendu initial et ignorée par la suite. Dès lors, c’est la fibre qui constitue la source de vérité, et non la valeur littérale présente dans le corps de la fonction.
C’est là la réponse complète au puzzle initial. Le compteur n’est ni dans la fonction, qui oublie tout entre deux appels, ni dans l’élément, qui est éliminé après chaque rendu. Il se trouve dans la fibre, un objet distinct que React conserve et met à jour à chaque rendu de Counter.
Deux conséquences pratiques
Celui-ci explique quelques comportements qui semblent sinon arbitraires :
- Changer l’argument de
useStateaprès le montage n’a aucun effet sur la valeur stockée, car React ne la lit qu’une seule fois. Si vous devez réinitialiser l’état à partir des props, modifiez lakeydu composant afin que React crée une nouvelle fibre. - Puisque la fibre est recherchée par position dans l’arbre, l’état appartient à l’endroit où un composant est rendu, et non à la définition de la fonction. Deux éléments
<Counter />côte à côte disposent de deux fibres et de deux comptes indépendants.
Que contient la fibre en dehors de ces deux champs
type et memoizedState suffisent pour résoudre le problème lié à l’état, mais une véritable fibre contient bien plus encore. Elle conserve une référence au nœud DOM qu’elle a généré, des pointeurs vers son parent, son premier enfant et son prochain frère/sœur afin que React puisse parcourir l’arbre, ainsi que les props précédents à comparer avec ceux reçus. Ces champs déterminent la manière dont React identifie ce qui a changé et comment il parcourt tout l’arbre des composants, ce qui constitue un problème distinct de la question de savoir où se trouve une valeur donnée.
Ce qui peut être clairement défini maintenant, c’est la ligne de séparation entre ces deux objets, car leur confusion est à l’origine de nombreuses difficultés en matière de rendu :
Element Fiber
-------- -----
Created by React.createElement Created internally by React
New object every render Same object, updated in place
Discarded right after Persists for the component's
React reads it entire mounted lifetime
Holds no history Holds memoizedState, the real
remembered value across renders
Describes what should exist Is the thing that actually exists,
with real memory attached to it
Une façon concise de s’en souvenir : un élément est une demande que vous adressez à React, tandis qu’une fibre est le propre enregistrement de React de ce qui existe actuellement. Chaque rendu génère un nouveau lot d’éléments peu coûteux et sans mémoire, qu’il transmet à l’arbre des fibres, déjà présent depuis le rendu précédent et qui contient tout ce qui doit persister. D’un côté, il y a la description ; de l’autre, le souvenir.
Un point à clarifier : les fibres viennent par paires
L’affirmation selon laquelle une fibre est "mise à jour sur place" constitue une première approximation utile, mais ce n’est pas toute la vérité. En réalité, React conserve deux versions de chaque fibre : l’une correspondant à ce qui est affiché actuellement et l’autre étant préparée pour la prochaine mise à jour, puis il alterne entre elles. Cet arrangement permet à React de travailler sur une mise à jour sans perturber l’interface utilisateur visible, et il mérite une explication distincte.
Points clés
- Les variables locales d’un composant fonctionnel sont recréées à chaque appel, ce qui empêche le composant lui-même de stocker un état.
- JSX se compile en appels qui génèrent des éléments : des descriptions simples et éphémères reconstruites à chaque rendu.
- Les fibres sont des objets simples qui persistent tant qu’un composant est monté ; le champ
typeindique quel composant la fibre suit. useStatelit et écrit des valeurs via le champmemoizedStatede la fibre, qui fait en réalité référence à une liste d’objets hook correspondant à l’ordre des appels.- La valeur initiale passée à
useStaten’a d’importance qu’à l’instant du montage ; par la suite, c’est la fibre qui constitue la source de vérité.
Lectures complémentaires
- Réfléchir au state de React : où vos données devraient réellement se trouver — Cet article explique comment réduire les bugs de React en plaçant le state dans l’URL, le DOM ou des valeurs dérivées, plutôt que d’utiliser excessivement useState.
- Comment le navigateur peint et où React occupe une place — Découvrez comment le Critical Rendering Path, la réconciliation, Fiber et le Scheduler fonctionnent ensemble pour transformer les mises à jour de React en pixels à l’écran.
- Pourquoi les règles des hooks existent : Fiber, listes de hook et dispatchers — Une présentation des mécanismes internes de React : les nœuds Fiber, le double buffering, les lanes, la liste chaînée des hook et la manière dont chaque famille de hook intégrée stocke son état et planifie ses tâches.