Accueil / Articles / Suivre une appel à React setState depuis la file d’actualisation jusqu’à l’exécution dans le DOM

Suivre une appel à React setState depuis la file d’actualisation jusqu’à l’exécution dans le DOM

Suivez pas à pas une mise à jour d’état React à travers la file d’attente des mises à jour, le planificateur, la phase de rendu, la réconciliation et l’enregistrement, afin de comprendre pourquoi l’état ne change jamais immédiatement.

2432 mots

Presque tous les développeurs React finissent par écrire un setteur d’état suivi d’un console.log et sont surpris de voir la valeur ancienne s’afficher. Le nom setState suggère une affectation immédiate, mais ce n’est pas ce que fait React : appeler un setteur enregistre une demande d’état futur, et toute une chaîne de traitement s’exécute avant que quoi que ce soit n’arrive à l’écran. Cet article suit une seule mise à jour à travers cette chaîne de traitement, depuis la file d’attente des mises à jour du Hook jusqu’à l’planification, le rendu, la réconciliation et la phase d’enregistrement. Une fois que vous pouvez visualiser chaque étape, plusieurs comportements qui semblent étranges au début deviennent prévisibles : pourquoi l’état semble asynchrone, pourquoi plusieurs mises à jour peuvent se fusionner en un seul rendu, pourquoi React saute parfois certaines étapes et pourquoi les mises à jour fonctionnelles existent.

Le fragment qui surprend tout le monde

Imaginez une revue de code où cela semble parfaitement raisonnable :

setCount(count + 1);

console.log(count);

On s’attend à ce que la console affiche la valeur incrémentée. Elle affiche plutôt la valeur précédente. Voici la même situation à l’intérieur d’un composant complet :

function Counter() {
    const [count, setCount] = useState(0);

    const handleClick = () => {
        setCount(count + 1);
        console.log(count);
    };

    return (
        <button onClick={handleClick}>
            {count}
        </button>
    );
}

Lors du premier clic, de nombreux développeurs s’attendent à voir :

1

Ce qui apparaît réellement dans la console, c’est :

0

La raison en est que React n’a pas encore été réexécuté. Au moment où console.log est exécuté, React n’a reçu qu’une demande. Rien dans la file d’attente n’a encore été appliqué, la fonction du composant n’a pas été appelée à nouveau, et le DOM n’a pas changé. Le code que vous exécutez fait partie de la réconciliation actuelle, et au sein de cette réconciliation count est une simple constante qui a été fixée lors du premier exécution de la fonction. Rien ne peut la réaffecter.

C’est là le premier changement dans le modèle mental : un setter ne modifie pas la variable d’état que vous lisez. Il planifie plutôt des tâches à effectuer par React plus tard.

Que stocke React lorsque vous appelez un setter

Lorsque vous écrivez :

setCount(count + 1);

il est tentant d’imaginer que React fait quelque chose comme ceci en interne :

count = count + 1

Ce n’est pas le cas. React crée un objet de mise à jour et l’ajoute à une file d’attente appartenant à ce Hook spécifique. Conceptuellement, la situation après le clic est la suivante :

Current State
      |
      ▼
count = 0
      |
      ▼
User Clicks
      |
      ▼
setCount(1)
      |
      ▼
Update Queue
[ Update: 1 ]

L’état lui-même reste inchangé. Tout ce que React a fait, c’est prendre une note pour lui-même : la prochaine fois que les mises à jour de ce Hook seront traitées, count devrait devenir 1.

Pourquoi les mises à jour passent par une file d’attente

Une file d’attente est utile dès lors que plusieurs mises à jour arrivent en même temps. Prenons un gestionnaire qui appelle le setter trois fois :

const handleClick = () => {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
};

Quelqu’un de nouveau en React pourrait s’attendre à ce qu’un seul clic produise :

count = 3

Le résultat réel est :

count = 1

Toutes les trois appels ont été effectués pendant la même mise à jour, et cette mise à jour a montré :

count = 0

Ainsi, chaque count + 1 donne le même résultat, et React reçoit trois demandes identiques :

setCount(1)
setCount(1)
setCount(1)

La file d’attente contient donc trois entrées indiquant toutes "set to 1" :

[1]
[1]
[1]

Leur traitement dans l’ordre se termine toujours par :

1

et non :

3

La même logique s’applique lorsque les valeurs diffèrent. Supposons qu’une mise à jour effectue ces trois appels :

setCount(1)
setCount(6)
setCount(4)

La file d’attente contient alors :

[1]
[6]
[4]

Chaque entrée remplace complètement l’état précédent, de sorte qu’après traitement, seul le dernier compte et le résultat est 4. Les valeurs simples ne sont que des remplacements, et non des instructions pour construire sur ce qui a précédé. C’est précisément cette limitation qui explique l’existence des mises à jour fonctionnelles.

Les mises à jour fonctionnelles calculent à partir de l’état le plus récent

Modifions maintenant le gestionnaire de manière à ce que chaque appel transmette une fonction :

const handleClick = () => {
    setCount(prev => prev + 1);
    setCount(prev => prev + 1);
    setCount(prev => prev + 1);
};

Cette fois, la file contient plutôt trois petits programmes que trois valeurs :

prev => prev + 1
prev => prev + 1
prev => prev + 1

Lorsque React traite la file, il passe la sortie de chaque fonction à la suivante :

0 → 1
1 → 2
2 → 3

et l’état final est :

count = 3

La différence réside dans le fait qu’une fonction de mise à jour ne capture pas de valeur provenant de la rendu actuel. Elle décrit comment dériver l’état suivant à partir de l’état que React possède au moment où il traite cette entrée. Utilisez cette forme chaque fois que le nouvel état dépend de l’état précédent, en particulier lorsque plusieurs mises à jour peuvent être enfileées ensemble ou lorsqu’une mise à jour a lieu à l’intérieur d’une fonction de rappel créée lors d’un rendu antérieur.

Tout le cycle de vie d’une mise à jour

En tenant compte de la file d’attente, suivez un simple clic qui appelle :

setCount(count + 1);

Une vue simplifiée de tout ce qui se passe ensuite :

User Click
     |
     ▼
Create Update
     |
     ▼
Place Update Into Queue
     |
     ▼
Notify React Scheduler
     |
     ▼
Schedule Render
     |
     ▼
Render Phase
     |
     ▼
Reconciliation
     |
     ▼
Commit Phase
     |
     ▼
DOM Updated

Les sections ci-dessous détaillent chaque étape.

Étape 1 : une entrée de mise à jour est créée

L’appel à setCount(1) ne rérend jamais directement quoi que ce soit :

setCount(1);

React crée une entrée de mise à jour, que l’on peut considérer comme une note indiquant :

Apply this update later.

Cet enregistrement est associé à l’état interne du Hook. Les données du Hook coexistent avec la Fiber du composant, le nœud interne que React conserve pour chaque instance de composant, ainsi qu’avec la file d’attente des mises à jour :

Fiber
   |
   └── useState
           |
           ├── Current State
           └── Update Queue

C’est aussi pour cette raison que les Hooks doivent être appelés dans le même ordre à chaque rendu : React trouve l’état et la file d’attente de chaque Hook en fonction de sa position dans cette liste.

Étape 2 : React planifie le travail

Avec une mise à jour en main, React doit décider quand la traiter. C’est là le rôle du planificateur. React ne rend pas nécessairement immédiatement dès qu’un setter est appelé ; il équilibre la réactivité et l’évitement de travaux inutiles.

Imaginez un utilisateur qui tape rapidement dans un champ :

A
AB
ABC
ABCD
ABCDE

Le rendu synchronisé des parties gourmandes de l’interface utilisateur après chaque frappe, sans possibilité de priorisation, rendrait les écrans lourds peu réactifs. Le planification permet à React de regrouper les mises à jour arrivant en même temps et, grâce à des fonctionnalités concurrentes comme les transitions, de traiter les mises à jour urgentes, telles que l’entrée elle-même, différemment des mises à jour moins urgentes, comme une liste de résultats filtrés. Cette capacité explique en grande partie pourquoi l’architecture de React a évolué vers des mises à jour planifiées plutôt que immédiates.

Étape 3 : la phase de rendu exécute à nouveau le composant

Lorsque React décide qu’il est temps de traiter les mises à jour en attente, il lance un nouveau rendu. Le rendu consiste simplement à appeler à nouveau la fonction du composant :

function Counter() {
    const [count, setCount] = useState(0);

    return <h1>{count}</h1>;
}

Le détail important concerne ce que fait useState lors de cette appel. Avant de renvoyer une valeur, il traite les mises à jour en file d’attente par rapport à l’état précédent. En partant de :

Previous State = 0

React parcourt la file d’attente :

Queue:
[ +1 ]Process QueueResult:
1

et la valeur que useState renvoie est désormais :

count = 1

que le composant utilise pour cette rendu. C’est pourquoi la nouvelle valeur n’apparaît que lors du prochain rendu : elle y est calculée, et non au moment où vous appelez le setter.

Étape 4 : un nouvel arbre d’éléments est généré

L’exécution du composant renvoie un arbre frais d’éléments React. Par rapport au rendu précédent, il ressemble à ceci :

Previous Render
<h1>0</h1>

New Render
<h1>1</h1>

Le DOM du navigateur n’a pas encore été modifié. À ce stade, tout ce que possède React, c’est un plan mis à jour de l’interface utilisateur prévue.

Étape 5 : la réconciliation détecte les différences

Ensuite, React compare le résultat précédent :

Old Tree

avec le nouveau :

New Tree

pour déterminer ce qui a réellement changé. Dans cet exemple, avant :

Before:
<h1>0</h1>

et après :

After:
<h1>1</h1>

Seul le texte à l’intérieur du titre diffère, ce qui constitue la seule modification enregistrée par React. C’est cette comparaison que l’on désigne sous le terme de réconciliation. Pour en savoir plus sur la manière dont React décide ce qu’il convient de conserver et ce qu’il faut remplacer, consultez la création d’un modèle mental pour la réconciliation, l’état et les Hooks dans React.

Étape 6 : la phase d’application touche le DOM

Lorsque la liste des modifications est connue, React entre dans la phase d’application et les applique au DOM réel :

DOM Before
<h1>0</h1>

DOM After
<h1>1</h1>

C’est seulement maintenant que l’utilisateur voit le nouveau nombre. C’est le moment que la plupart des gens imaginent lorsqu’ils appellent un setter, mais c’est en réalité la dernière des plusieurs étapes.

Pourquoi React n’applique pas les mises à jour immédiatement

Considérez un gestionnaire qui met à jour plusieurs éléments d’état en même temps :

setCount(c => c + 1);
setLoading(false);
setUser(data);

Si chaque appel déclenchait sa propre rendu, vous obtiendriez :

Render 1
Render 2
Render 3

Cela correspond à trois rendus, dont deux inutiles, ainsi que possiblement des écrans intermédiaires affichant des combinaisons d’état incohérentes. Au lieu de cela, React regroupe les mises à jour. Les appels sont mis en file :

Update
Update
Update

et ensuite traités ensemble :

      |
      ▼Single Render

Le regroupement des mises à jour évite des rendus redondants et garantit que l’utilisateur ne voie jamais qu’un état final cohérent. C’est la raison principale pour laquelle React préfère planifier les mises à jour plutôt que de les exécuter immédiatement. React peut également sauter complètement certaines opérations : si une mise à jour produit une valeur identique à la valeur actuelle, selon le critère défini par Object.is, React peut s’arrêter sans rérender les enfants du composant.

Comment cela se manifeste en pratique

Prenons une boîte de recherche où chaque frappe met à jour trois éléments d’état :

query
results
loading

Il est naturel de penser que chacun de ces fonctions de mise à jour provoque un rendu distinct. Cependant, l’analyse des performances d’un tel composant montre souvent le contraire : les mises à jour effectuées ensemble au sein du même événement sont regroupées, ce qui fait que le composant se rend moins souvent que ne le laisserait supposer une lecture naïve du code. La tâche de React n’est pas seulement d’appliquer les mises à jour, mais aussi de le faire de manière efficace.

Lorsque plusieurs composants ont des mises à jour en attente

Les mises à jour ne se limitent pas à un seul composant. Prenons un arbre comme celui-ci :

App
 ├── Header
 ├── Sidebar
 └── Dashboard

Si plusieurs mises à jour arrivent dans différentes parties de cet arbre, React n’a pas besoin de reconstruire tout le système de manière aveugle. L’arbre Fiber permet à React de suivre :

  • quels composants ont des tâches en attente
  • où dans l’arbre chaque mise à jour provient
  • quels sous-arbres doivent être visités et lesquels peuvent être sautés

C’est ce qui permet à React d’être bien plus sélectif qu’une approche consistant à « ré-renderiser toute l’application à chaque modification ». Notez qu’un composant qui se ré-renderise rérendera néanmoins ses enfants par défaut ; c’est la mémorisation qui permet de sauter les sous-arbres inchangés.

Revenir sur le cas du console.log obsolète

Retournons au fragment initial :

setCount(count + 1);
console.log(count);

La console affiche :

0

car le code est encore en cours d’exécution au sein de la rendu actuel. La file n’a pas été traitée, le prochain rendu n’a pas eu lieu et la mise à jour attend son tour. Une façon plus précise de l’imaginer :

Current Render
count = 0

Request Update
Future Render
count = 1

Si vous avez besoin de la nouvelle valeur immédiatement, calculez-la dans une variable locale et utilisez-la, ou lisez-la lors du prochain rendu ou dans un effet qui en dépend. Vu de cette manière, le comportement n’est pas du tout étrange.

Comment les éléments se connectent

Ensemble, ces étapes forment une seule chaîne :

  • L’état est stocké avec les données Hook d’un composant.
  • Les données Hook sont attachées au Fiber du composant.
  • L’appel d’un setter crée une mise à jour.
  • Les mises à jour sont ajoutées à la file du Hook.
  • Le planificateur choisit le moment opportun pour les traiter.
  • La phase de rendu appelle à nouveau le composant et applique la file.
  • La réconciliation compare l’arbre d’éléments nouveau avec l’ancien.
  • La phase de commit applique les différences au DOM.
  • Les concepts qui sont souvent enseignés séparément ne sont en réalité que des étapes successives au sein d’un même processus. En une phrase : une appel à un setter laisse l’état tel quel et demande plutôt une mise à jour planifiée, que React applique lors d’un rendu ultérieur, compare avec la sortie précédente et ne modifie le DOM que là où des différences existent.

    Points clés

    • Un setter ne modifie jamais l’état directement ; il enregistre une mise à jour à traiter par React ultérieurement.
    • Les mises à jour sont enregistrées par Hook, ce qui permet de gérer plusieurs d’entre elles lors d’un seul rendu.
    • Des valeurs simples remplacent l’état ; les fonctions de mise à jour dérivent l’état suivant à partir de l’état actuel, ce qui évite les valeurs obsolètes.
    • React planifie les tâches plutôt que de les exécuter à chaque appel, ce qui rend possible le regroupement et la priorisation des opérations.
    • Le rendu et la mise à jour du DOM sont des étapes distinctes : le rendu produit une description, tandis que la phase d’application modifie le navigateur.
    • La file d’attente des mises à jour est stockée avec l’état du Hook sur le Fiber du composant.

    Considérer setState comme une demande plutôt que comme un ordre est un petit changement de formulation ayant des conséquences importantes. C’est ce qui permet le regroupement, la planification, la réconciliation et le rendu concurrentiel. La question suivante naturelle est de savoir comment React détermine si les mises à jour en file produisent un seul rendu ou plusieurs, ce que le regroupement automatique, introduit dans React 18, permet de résoudre.