Accueil / Articles

Pourquoi setState ne réaffiche jamais immédiatement : planification des mises à jour de React

Suivez une mise à jour d’état React depuis l’objet de mise à jour en file d’attente, en passant par le marquage des chemins et la planification, jusqu’au cycle de travail qui commence par la racine et ignore les sous-arbres non affectés.

Éditeur : REACTAPP.TOP8 min de lecture1762 mots

Sources: The Fiber Architecture In React Part 3: What Actually Happens When You Call setState

Presque tous les développeurs React partagent une supposition tacite : appeler un setteur d’état comme setCount(count + 1) fait réexécuter immédiatement le composant avant même que la ligne suivante ne soit exécutée. Ce n’est pas le cas, et l’écart entre l’appel et la réaffichage explique le fonctionnement en lots, pourquoi les composants non liés restent inchangés, et pourquoi lire l’état immédiatement après l’avoir mis à jour renvoie la valeur ancienne. Cet article suit une seule mise à jour à travers l’architecture Fiber de React, du moment où elle est enregistrée jusqu’au moment où la nouvelle valeur est stockée dans l’état du composant, afin que vous puissiez comprendre ce que font réellement vos setteurs d’état.

Le composant sous le microscope

function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

La fonction setCount(count + 1) ressemble à une fonction synchrone ordinaire, ce qui amène naturellement à penser que React appelle à nouveau Counter() directement suite à cette ligne. Rien de tel ne se produit : le setter renvoie sans afficher quoi que ce soit. Ce qu’il fait plutôt, c’est enregistrer une demande et demander que le travail soit effectué plus tard.

Étape un : le setter enregistre une mise à jour

Lorsque setCount(1) est exécuté, React alloue dans la mémoire un petit objet de mise à jour qui décrit cette demande. Conceptuellement, cela se présente comme ceci :

{
  action: 1,
  next: null,
}

Le champ action contient ce que vous avez passé, ici la valeur 1. Le champ next existe parce qu’une seule mise à jour ne suffit rarement. Appelez le setter trois fois à l’intérieur d’un même gestionnaire, et React crée trois objets de mise à jour liés entre eux via next, formant une petite liste attachée au composant. Dans le code source réel de React, la mise à jour comporte également une priorité (son « lane ») et la liste en attente est stockée sous forme de structure circulaire, mais l’idée fondamentale reste une file d’intentions, et non un re-render.

Dans un modèle simplifié de la fibre, la mise à jour est attachée de cette manière :

{
  type: Counter,
  memoizedState: { count: 0 },   // still 0, untouched
  updateQueue: { action: 1, next: null },   // the note, just added
}

Remarquez ce qui n’a pas changé. L’état stocké affiche toujours 0, l’écran montre toujours 0, et Counter() n’a pas été appelé. La seule différence est qu’un objet attend désormais dans une file d’attente. (Pour plus de précision : avec les hooks, React conserve la file d’attente de chaque hook dans l’entrée correspondante de sa liste de hooks au sein du fiber, plutôt que directement dans un champ appelé updateQueue ; memoizedState dans un composant fonctionnel correspond justement à cette liste de hooks. Le modèle simplifié présenté ci-dessus sert à des fins pédagogiques et conserve le comportement essentiel ici.)

Cela explique également une surprise classique : si vous affichez la valeur de count immédiatement après avoir appelé setCount, vous verrez la valeur ancienne, car la fonction de composant qui utilise count n’a pas encore été exécutée à nouveau.

Étape deux : scheduleUpdateOnFiber trace un chemin et demande des tâches

Dès que la mise à jour est en file d’attente, React appelle une fonction interne nommée scheduleUpdateOnFiber. Celle-ci a deux responsabilités distinctes, et les séparer facilite la compréhension du reste du processus.

Marquage du chemin menant à la racine

Tout d’abord, React remonte depuis la fibre Counter à travers chaque pointeur parent jusqu’à atteindre la racine de l’arbre. Sur chacun des ancêtres rencontrés, il définit un champ appelé childLanes, ce qui signifie en fait « il y a des tâches en attente en dessous de moi ». La fibre Counter elle-même dispose d’un champ correspondant, lanes, qui indique « j’ai moi-même des tâches en attente ».

Ces champs ne sont pas de simples booléens. Il s’agit de masques binaires qui encodent la priorité, un sujet à part entière ; consultez comment React lanes utilisent des masques binaires pour encoder la priorité des mises à jour. Pour cette explication, il suffit de savoir qu’ils passent d’un état vide à un état non vide, laissant ainsi une trace allant du composant mis à jour jusqu’en haut de la hiérarchie.

Report du rendu réel

Deuxièmement, React demande que le rendu s’exécute rapidement, et non à l’intérieur de votre gestionnaire d’événements. Le mécanisme exact dépend de la priorité. Les tâches gérées par le planificateur de React sont généralement enregistrées via MessageChannel (avec setTimeout en tant que solution de repli dans les environnements qui ne le disposent pas), ce qui fait qu’elles s’exécutent lors d’une prochaine itération du cycle d’événements. Pour des événements discrets à haute priorité tels que les clics, les versions modernes de React exécutent généralement ces tâches en tant que microtâche immédiatement après la fin du gestionnaire d’événements, ce qui reste après votre code, mais plus tôt. La stratégie exacte a évolué entre les différentes versions, il convient donc de considérer ces détails comme spécifiques à chaque version.

Quel que soit le canal, la conséquence est la même : le rendu est délibérément différé jusqu’au retour de votre gestionnaire. C’est ce qui rend le regroupement possible. Trois appels à setCount dans un même gestionnaire génèrent trois mises à jour en file d’attente, et une seule passe de rendu ultérieure les traite toutes en même temps au lieu de réaliser trois rendus distincts.

Étape trois : le rendu commence toujours à la racine

Lorsque le travail planifié s’exécute enfin, il ne commence pas à Counter. Il débute à la fibre racine, le nœud spécial situé au sommet de toute votre application et qui a été créé lorsque vous avez appelé ReactDOM.createRoot. Cela se produit quel que soit le niveau de profondeur dans l’arborescence d’où provient la mise à jour.

Commencer depuis la racine peut sembler coûteux, mais cela ne signifie pas qu’il faut visiter chaque nœud. La série de childLanes définie à l’étape deux est précisément ce que parcourt la fonction. À chaque nœud, React vérifie si le sous-arbre inférieur contient des tâches en attente. S’il n’y en a pas, React s’arrête et saute tout le sous-arbre sans descendre plus profondément. Les ancêtres situés sur le chemin marqué sont traités rapidement, et seuls les branches menant à des tâches en attente sont explorées.

Une boucle, pas une récursion

La parcours est mis en œuvre sous forme de boucle plutôt que par récursion profonde :

let node = rootFiber;
while (node !== null) {
  node = processNode(node);   // moves to node.child, node.sibling, or up via node.return
}

Chaque fibre contient trois pointeurs : child pour son premier enfant, sibling pour la fibre suivante au même niveau, et return pour son parent. Ces trois liens suffisent pour descendre vers les enfants, passer aux frères et sœurs, puis remonter une fois une branche terminée, étape par étape. Il n’y a pas de tableau d’enfants à itérer. Comme la position dans l’arbre est stockée dans une variable plutôt que sur la pile d’appel, React peut s’arrêter entre des unités de travail et reprendre plus tard, ce qui constitue le fondement du rendu concurrentiel.

Pourquoi les composants non liés restent inchangés

C’est le mécanisme à l’origine d’un effet que vous avez probablement observé. Saisir du texte dans un champ met à jour uniquement la partie de l’arbre qui possède cet état. Un composant éloigné et non lié n’est pas réaffiché, ni même consulté, car aucune mise à jour n’a marqué sa branche. Ce saut n’est pas une optimisation spéciale ajoutée ultérieurement ; il découle directement du suivi de la piste childLanes.

Gardez ces limites à l’esprit. Un composant situé sur le chemin marqué qui possède lui-même des tâches en attente sera réaffiché, et par défaut l’auront également tous ses descendants, marqués ou non, à moins qu’ils ne soient mémorisés. La piste indique à React où aller ; elle n’empêche pas un parent en cours de réaffichage de le faire pour ses enfants.

Étape quatre : arriver à Counter et consommer la file d’attente

Lorsque la boucle atteint Counter, le mécanisme de double tamponnage prend le relais. React vérifie le pointeur alternate de la fibre en cours et réutilise soit la fibre en cours de traitement existante, soit il en crée une nouvelle. C’est dans cette copie en cours de traitement que l’état nouveau est calculé.

Désormais, la file d’attente est enfin lue. React traite la mise à jour en attente, applique l’action à l’état précédent et écrit le résultat dans memoizedState de la fibre en cours de traitement. Au cours de cette étape, Counter() est effectivement appelé, et useState renvoie la nouvelle valeur. Si plusieurs mises à jour ont été enregistrées dans la file, elles sont appliquées dans l’ordre ; les mises à jour fonctionnelles telles que setCount(c => c + 1) reçoivent chacune le résultat de la précédente, c’est pourquoi elles fonctionnent correctement lorsqu’elles sont regroupées, tandis que setCount(count + 1) trois fois ne provoque qu’une seule augmentation.

// work-in-progress fiber, right after processing the queue
{
  type: Counter,
  memoizedState: { count: 1 },   // finally updated, using the queued action
  updateQueue: null,             // consumed, nothing left pending
  alternate: Fiber A,
}

La file d’attente est maintenant vide et le nouvel état se trouve sur la fibre en cours de traitement. Trois mécanismes distincts ont été activés : la mise à jour en file d’attente de l’étape un, le suivi des traces marquées ainsi que la progression en partant de la racine des étapes deux et trois, et le couple fibre courante/en cours de traitement qui contient les versions avant et après. Pour un suivi plus complet couvrant également la réconciliation et l’application au DOM, consultez le suivi d’une appel setState React depuis la file d’attente des mises à jour jusqu’à l’application au DOM.

La séquence complète

Du début à la fin, un seul clic traverse ces étapes :

  • Le gestionnaire de clic appelle setCount, et React crée un objet de mise à jour qu’il ajoute à la file d’attente du composant.
  • scheduleUpdateOnFiber définit les lanes sur le Counter ainsi que les childLanes sur chaque ancêtre jusqu’à la racine.
  • La même fonction demande une passe de rendu qui s’exécute après le retour du gestionnaire.
  • La passe de rendu commence à la fibre racine et progresse via child, sibling et return, en sautant les sous-arbres ne contenant pas de lanes en attente.
  • Au niveau du Counter, React prépare la fibre en cours de traitement, traite les mises à jour en file d’attente et calcule le nouvel état.
  • La phase de validation applique les modifications au DOM, et l’arbre en cours de traitement devient le nouvel arbre actif.
  • Rien de tout cela ne se produit sur la ligne où vous avez appelé le setter. Tout s’exécute par la suite, au sein d’une structure dont les champs existent spécifiquement pour prendre en charge ce processus différé et reprendable.

    Points clés

    • lanes et childLanes indiquent le chemin allant de la fibre mise à jour jusqu’à la racine, permettant à React de sauter les sous-arbres non modifiés.
    • child, sibling et return au lieu de la récursivité, ce qui permet à React de suspendre et de reprendre son travail.