Accueil / Articles / Pourquoi existent les règles des hooks : Fibres, listes de hooks et dispatchers

Pourquoi existent les règles des hooks : Fibres, listes de hooks et dispatchers

Une visite des mécanismes internes de React : les nœuds Fiber, le double buffering, les lanes, la liste chaînée des hooks et la manière dont chaque famille de hooks intégrés stocke son état et planifie ses tâches.

2852 mots

La plupart des développeurs React connaissent par cœur les règles relatives aux hooks, mais beaucoup moins nombreux sont ceux qui peuvent expliquer pourquoi en les enfreignant on corrompt l’état au lieu de générer simplement une erreur utile. La réponse se trouve dans les mécanismes internes de React : chaque appel à un hook devient un nœud dans une liste chaînée stockée sur un Fiber, et React identifie chaque nœud uniquement selon l’ordre d’appel des hooks. Ce guide décrit en détail le Fiber, l’objet hook, les dispatchers que React alterne lors des rendus, ainsi que les mécanismes internes de chaque famille de hooks, afin que ces règles ne semblent plus arbitraires et ressemblent plutôt à des conséquences directes de la conception.

Vous n’aurez pas besoin de ces connaissances pour écrire un formulaire ou récupérer des données. Elles s’avèrent utiles lorsque vous déboguez une valeur obsolète, que vous devez choisir entre useEffect et useLayoutEffect, ou que vous vous demandez pourquoi un enfant mémorisé se re-renderise malgré tout.

Un bref rappel : ce que les hooks ont remplacé et les règles qui les accompagnent

Les hooks sont arrivés avec React 16.8, et l’ensemble intégré en compte désormais une dizaine-sept environ. Ils ont résolu trois problèmes persistants des composants de classe : la logique basée sur l’état était difficile à réutiliser entre composants, la logique liée était dispersée dans les méthodes de cycle de vie ce qui rendait les composants encombrés, et les classes JavaScript elles-mêmes (le liage de this, la compréhension des cycles de vie) confondaient de nombreux développeurs.

Avec les hooks est apparue une série de règles :

  1. Appeler les hooks au niveau le plus élevé du corps d’un composant fonctionnel.
  2. Appeler les hooks au niveau le plus élevé du corps d’un hook personnalisé.
  3. N’appeler jamais les hooks à l’intérieur de conditions ou de boucles.
  4. N’appeler jamais les hooks après un return précoce conditionnel.
  5. N’appeler jamais les hooks à l’intérieur des gestionnaires d’événements.
  6. N’appeler jamais les hooks dans les composants de classe.
  • N’appelez jamais de hooks à l’intérieur des callbacks que vous passez à useEffect, useMemo ou useReducer.
  • N’appelez jamais de hooks à l’intérieur des blocs try, catch ou finally.
  • Les violations génèrent des avertissements, des erreurs ou, pire encore, des bugs subtils. L’explication simple est que les hooks d’un composant forment une liste chaînée unique attachée à un nœud Fiber, qui est un objet JavaScript à état que React conserve pour chaque composant. Pour comprendre pourquoi c’est important, commencez par Fiber lui-même. Si vous le connaissez déjà bien, passez directement à la section sur l’objet hook. Pour une introduction plus douce aux mécanismes de réconciliation et à l’état, consultez comment construire un modèle mental pour la réconciliation, l’état et les hooks dans React.

    React Fiber : l’engin dans lequel vivent les hooks

    Fiber est le moteur de réconciliation de React, introduit dans React 16 comme une réécriture complète de la manière dont React calcule et applique les mises à jour de l’interface utilisateur.

    Le problème avec l’ancien réconciliateur en pile

    Au préalable de Fiber, React utilisait ce que l’on appelle souvent le réconciliateur en pile. À chaque mise à jour, il parcourait l’arbre des composants de manière récursive, et un parcours récursif sur la pile d’appels JavaScript ne peut pas être interrompu en cours de route : une fois lancé, il se poursuit jusqu’à ce que tout l’arbre soit traité. Sur un arbre volumineux qui monopolisait le seul thread principal, les animations se figeaient, les frappes de touche étaient retardées et l’interface fonctionnait de manière hachée. Pire encore, il n’était pas possible de faire en sorte qu’une mise à jour urgente, comme une frappe de touche, prenne le pas sur un rendu volumineux mais moins important déjà en cours.

    Ce que Fiber rend possible

    Une Fiber est un objet JavaScript simple qui représente une unité de travail, liée à une instance de composant ou à un nœud DOM. Comme React suit lui-même ces unités au lieu de s’appuyer sur la pile d’appels, il acquiert trois capacités :

    • Pause et reprise. React peut s’arrêter au milieu du traitement, laisser le navigateur gérer des éléments plus urgents tels que les entrées de l’utilisateur, puis reprendre depuis le même endroit plus tard.
    • Priorisation. Les mises à jour urgentes peuvent prendre le pas sur celles qui sont moins importantes.
    • Réutilisation ou suppression. Si l’utilisateur quitte la page pendant qu’un rendu est en cours, le travail inachevé peut simplement être éliminé.

    La structure d’un nœud Fiber

    Chaque élément React, qu’il s’agisse d’un composant, d’un élément DOM hôte ou d’un nœud de texte, dispose d’une Fiber correspondante. Il s’agit d’un objet volumineux qui contient les props du composant, son état ainsi qu’un lien vers sa représentation DOM.

    Au lieu de stocker les enfants dans des tableaux, les fibres forment un arbre à l’aide de trois pointeurs :

    • child mène au premier enfant de la fibre.
    • sibling mène à la fibre suivante au même niveau.
    • return ramène vers le parent.

    Traiter une mise à jour signifie suivre ces liens : descendre autant que possible via child, se déplacer latéralement via sibling, et remonter via return une fois une branche terminée. Comme il s’agit d’une boucle ordinaire sur des pointeurs plutôt que de récursion, React peut s’arrêter entre n’importe quelles deux fibres.

    Bufférage double avec deux arbres

    Fiber emprunte le concept de bufférage double aux programmes graphiques. À tout moment, React conserve deux arbres de fibres en mémoire :

    • L’arbre actuel reflète exactement ce qui apparaît à l’écran. React ne le modifie pas lors du calcul des mises à jour.
    • L’arbre en cours de traitement (WIP) est créé en arrière-plan lorsque quelque chose change. React clone les fibres actuelles qui nécessitent une mise à jour et assemble la nouvelle version à côté de l’ancienne.

    Lorsque l’arbre WIP est prêt, React change le pointeur racine. L’arbre WIP devient actuel et l’écran reflète le nouvel état. Chaque fibre conserve un pointeur alternate vers son équivalent dans l’autre arbre, c’est ainsi que l’état des hooks est transmis d’une rendu à l’autre.

    Phase de rendu et phase d’application

    Cette architecture divise chaque mise à jour en deux phases.

    La phase de rendu est interrompable. React parcourt l’arbre, appelle les fonctions de votre composant, exécute les hooks et compare le résultat à l’arbre actuel tout en construisant l’arbre WIP en mémoire. Comme React contrôle la boucle de parcours, son planificateur peut céder le contrôle au navigateur tous les quelques millisecondes. Si l’utilisateur tape pendant qu’un rendu à faible priorité est en cours, React peut s’arrêter, gérer l’entrée et reprendre. Il peut même supprimer tout l’arbre WIP lorsque une mise à jour plus récente et urgente le rend obsolète. Étant donné qu’un rendu peut s’exécuter plusieurs fois ou ne jamais aboutir, les fonctions de composant doivent être pures : aucun effet secondaire pendant le rendu.

    La phase d’application des modifications est synchrone. Une fois l’arbre WIP terminé, React applique d’un seul coup les changements calculés au DOM réel. Cette étape ne peut pas être interrompue, car s’arrêter en cours de mutation du DOM montrerait à l’utilisateur une interface partiellement mise à jour et incohérente. Les effets de mise en page s’exécutent ici, les effets passifs sont planifiés à partir d’ici, et les refs y sont attachés.

    Lanes : comment React décide ce qui doit être interrompu

    Pour savoir ce qui peut interrompre quoi, React attribue à chaque mise à jour une lane. Les lanes sont représentées par des bits dans un masque de bits, ce qui permet une combinaison et une comparaison rapides des priorités. En gros :

    • Une lane synchrone pour les interactions discrètes et urgentes telles que les clics et les appuis sur les touches (les événements continus comme le survol ou le défilement disposent de leur propre lane à haute priorité).
    • Des lanes de transition pour les tâches pouvant être interrompues : mises à jour en arrière-plan, réaffichages basés sur des données, changement de onglets.
  • Lignes de tentative pour les limites Suspense en cours de résolution.
  • On ne touche jamais directement à Fiber, pourtant c’est elle qui sous-tend les fonctionnalités phares de React 18 et 19. Le rendu concurrent, Suspense, useTransition et useDeferredValue dépendent tous du fait que le rendu puisse être interrompu.

    L’objet hook et pourquoi l’ordre des appels est essentiel

    Les composants fonctionnels n’ont pas d’instance pour conserver l’état, donc React le stocke sur la fibre dans un champ appelé memoizedState. Pour les composants fonctionnels, ce champ fait référence au premier hook d’une liste chaînée simple.

    Chaque appel de hook pendant le rendu correspond à un objet ayant plus ou moins cette forme :

    {
      memoizedState: any,        // The internal state of the hook
      baseState: any,            // The state before any unprocessed updates
      baseQueue: Update | null,  // Updates that were skipped due to priority
      queue: UpdateQueue | null, // Circular linked list of pending state updates
      next: Hook | null          // Pointer to the next hook in the component
    }
    

    Le memoizedState du hook conserve sa valeur (l’état pour useState, le registre d’effets pour useEffect, la paire mémorisée pour useMemo), queue stocke les mises à jour en attente, baseState et baseQueue suivent les mises à jour qui ont été ignorées car leur traitement n’était pas en cours, et next fait référence au hook suivant.

    Remarquez ce qui manque : il n’y a ni clé ni nom. Lors d’un nouveau rendu, React parcourt simplement la liste depuis le début, associant la première appel de hook au premier nœud, la deuxième à celui suivant, et ainsi de suite. Si un hook est appelé à l’intérieur d’un if et que la condition change, chaque appel ultérieur est associé au mauvais nœud, ce qui provoque un fuitement d’état d’un hook vers un autre. C’est la raison fondamentale des Règles des hooks : elles garantissent que les mêmes hooks s’exécutent dans le même ordre à chaque rendu. Les règles concernant les boucles, les retours prématurés, les blocs try et les callbacks ne sont que des variantes de cette même exigence.

    Une exception moderne confirme ce principe : l’API use dans React 19 peut être appelée conditionnellement, précisément parce qu’elle ne dépend pas de la même manière d’un emplacement spécifique dans cette liste.

    Dispatchers : le même nom de hook, différentes implémentations

    Le useState que vous importez n’est qu’un enveloppe légère. Lors de l’exécution, il redirige vers le dispatcheur que React a installé pour la phase actuelle. Les versions anciennes de React exposent cela via ReactCurrentDispatcher ; dans les versions récentes, le dispatcheur se trouve dans l’objet partagé interne de React, mais le principe reste identique.

    • HooksDispatcherOnMount est actif lors de la première rendu. useState est associé à mountState, qui alloue un nouvel objet de hook, définit son état initial et l’ajoute à la fin de la liste.
    • HooksDispatcherOnUpdate est actif lors des re-rendus. useState est associé à updateState, qui parcourt la liste existante (en pratique workInProgressHook = workInProgressHook.next), traite la file d’attente en attente et renvoie l’état mis à jour.
  • ContextOnlyDispatcher est installé chaque fois que React ne rend pas un composant. Tout hook appelé à travers lui provoque une erreur, d’où apparaît l’erreur « invalid hook call » lorsque vous appelez un hook en dehors d’un composant.
  • Cette conception explique également pourquoi l’appel de hooks à l’intérieur des gestionnaires d’événements échoue : au moment où le gestionnaire s’exécute, le rendu est déjà terminé et le dispatcher générant des erreurs est en place.

    Fonctionnement interne de chaque famille de hooks

    Tous les hooks partagent la base d’une liste chaînée, mais ils diffèrent fortement quant à ce qu’ils stockent et au moment où leur traitement a lieu.

    Hooks d’état : useState et useReducer

    Internement, useState est en réalité useReducer avec un réducteur intégré qui retourne soit la nouvelle valeur, soit appelle votre fonction de mise à jour avec la valeur précédente. Les deux partagent un même modèle d’exécution :

    • Stockage. Le crochet conserve un état de base (la valeur la plus récemment enregistrée) ainsi qu’une file d’attente des mises à jour, qui est une liste chaînée circulaire contenant les modifications en attente.
    • Déploiement. L’appel d’un setteur, par exemple setCount(c => c + 1), crée un objet de mise à jour contenant cette action, l’ajoute à la file d’attente et marque la fibre comme nécessitant des traitements en lui attribuant une voie d’exécution (les versions plus anciennes utilisaient des délais d’expiration à cette fin).
    • Résolution.Lors du prochain rendu, React parcourt la file d’attente et applique chaque action dans l’ordre afin de générer le nouveau memoizedState. Les mises à jour dont la voie d’exécution n’est pas incluse dans le rendu actuel sont conservées dans baseQueue et exécutées ultérieurement, ce qui permet de préserver l’ordre entre les différentes priorités.

    C’est aussi pour cette raison que les fonctions de mise à jour constituent le choix sûr lorsque l’état suivant dépend de l’état précédent : elles sont appliquées séquentiellement sur l’état que la file d’attente a calculé jusqu’à présent.

    Crochets d’effet : useInsertionEffect, useLayoutEffect et useEffect

    Chaque crochet d’effet stocke un enregistrement d’effet dans son état de crochet, contenant la fonction de configuration, la fonction de nettoyage et le tableau des dépendances. Ces enregistrements sont également ajoutés en chaîne à une liste distincte dans updateQueue de la fibre, et les effets dont les dépendances ont changé sont marqués afin que la phase d’exécution sache lesquels lancer. Les trois crochets diffèrent par leur moment d’exécution :

    • useInsertionEffect s’exécute avant les effets de mise en page, avant tout code susceptible de lire la structure du DOM. Il est destiné aux bibliothèques CSS-in-JS qui doivent injecter des règles <style> tôt afin que les styles soient prêts au moment où la mise en page est calculée, évitant ainsi de recalculer les styles à plusieurs reprises.
    • useLayoutEffect s’exécute de manière synchrone une fois que React a modifié le DOM, mais avant que le navigateur n’ait la possibilité de l’afficher. Le thread principal est bloqué jusqu’à ce que l’effet et sa nettoyage soient terminés, ce qui le rend adapté à la mesure et au ajustement des nœuds DOM avant que l’utilisateur ne voie quoi que ce soit, mais pas à des tâches plus lourdes.
  • useEffect est passif. Il s’exécute généralement après que le navigateur ait affiché l’élément, planifié via le planificateur de React (qui utilise MessageChannel, avec comme fallback setTimeout), de sorte qu’il ne retarde pas la mise à jour visuelle. Notez que lorsque l’actualisation provient d’une entrée utilisateur discrète, React peut exécuter les effets passifs avant l’affichage.
  • Hooks de performance : useMemo et useCallback

    Ce sont des mémoires qui évitent des récalculs coûteux ou maintiennent stables les références entre les rendus.

    • Stockage. Le hook stocke une paire : la valeur mémorisée et le tableau de dépendances avec lesquelles elle a été calculée.
    • Exécution.Lors d’un nouveau rendu, React compare chaque nouvelle dépendance avec celle en mémoire tampon à l’aide de Object.is. Si tout correspond, la fonction factory n’est pas appelée et la valeur en mémoire tampon est renvoyée. S’il y a des différences, React appelle la fonction factory, enregistre la nouvelle valeur ainsi que les nouvelles dépendances, puis renvoie le résultat.
    • useCallback est équivalent à useMemo(() => fn, deps) : il conserve l’objet fonction que vous avez passé plutôt que la valeur produite en l’appelant. La version source de React le met en œuvre séparément, mais le comportement reste identique.

    Puisque la comparaison est superficielle, une dépendance qui représente un objet ou un tableau nouvellement créé à chaque rendu annule complètement l’effet du cache.

    Hooks de valeurs modifiables : useRef et useImperativeHandle

    Les refs stockent des informations qui ne sont pas utilisées pour le rendu, telles qu’un nœud DOM ou un identifiant de délai.

    • useRef est sans doute le hook le plus simple de l’ensemble du code. Lors du montage, il crée { current: initialValue } et le stocke comme état du hook ; chaque rendu ultérieur renvoie exactement le même objet. Écrire dans current n’affecte jamais la file d’attente des mises à jour ni les canaux associés, ce qui empêche toute exécution de rendu.
    • useImperativeHandle permet de personnaliser ce que voit un parent via un ref en y attachant ses propres méthodes. Internement, il fonctionne comme useLayoutEffect : il s’exécute de manière synchrone lors du commit, de sorte que le handle est prêt au moment où les effets du parent sont exécutés. Dans React 19, les composants fonctionnels peuvent recevoir ref en tant que propriété ordinaire, ce qui rend forwardRef inutile pour l’utiliser.

    Le hook de contexte : useContext

    useContext se distingue par le fait qu’il ne occupe jamais de place dans la liste des hooks.

    • Lecture. Il lit la valeur du Provider le plus proche situé au-dessus du composant et enregistre ce contexte dans la liste des dépendances de la fibre.
    • Propagation.Lorsque la valeur d’un Provider change, React cherche en dessous celui-ci des fibres dont les dépendances incluent ce contexte et planifie leur rérendu. Cela se produit même si un composant intermédiaire utilise React.memo ou shouldComponentUpdate pour interrompre le processus, d’où le fait que la mise en mémoire d’un parent ne protège pas les consommateurs de contexte.

    Hooks concurrents : useTransition et useDeferredValue

    Cette paire vous permet de gérer le système de files d’attente, ce qui permet d’interrompre des rérenders longs.

    • useTransition renvoie [isPending, startTransition]. Les mises à jour effectuées à l’intérieur de startTransition(() => setQuery(text)) reçoivent une voie de transition plutôt qu’une voie urgente. Si un clic ou une touche est enregistré pendant que la transition est affichée, React abandonne l’arbre en cours de traitement, gère la mise à jour urgente, puis redémarre la transition depuis zéro.
    • useDeferredValue enrobe une valeur plutôt qu’un définisseur. React conserve en fait deux versions : il affiche d’abord avec la valeur précédente afin que l’écran reste réactif, puis planifie une affichage en arrière-plan à faible priorité avec la nouvelle valeur.

    Hooks spécialisés : useId et useSyncExternalStore

    • useId évite les incohérences de hydration lors du rendu côté serveur. Il déduit un ID à partir de la position du composant dans l’arbre. Comme la structure de l’arbre est identique sur le serveur et le client pendant l’hydratation initiale, les IDs coïncident sans besoin de compteur global.
    • useSyncExternalStore remplace les abonnements manuels à des stores externes tels que Redux ou Zustand, réalisés avec useEffect. On lui fournit deux fonctions : l’une qui enregistre un écouteur de changement sur le store, et getSnapshot, qui renvoie la valeur actuelle du store. React lit ce snapshot pendant le rendu et, si le store change en cours de rendu, il rérendit de manière synchrone afin qu’aucune partie de l’interface n’affiche une version différente du store par rapport à une autre. Cela empêche les problèmes de déchirure visuelle que le rendu concurrentiel pourrait autrement provoquer.

    Points clés

    • L’état des hooks est une liste chaînée au sein de la fibre, déterminée uniquement par l’ordre des appels ; chaque règle relative aux hooks existe pour maintenir cet ordre identique lors de toutes les rendus.
    • La fibre transforme le processus de rendu en une boucle interrompable, et le double buffer permet à React de préparer un nouvel arbre sans modifier ce qui est affiché à l’écran.
    • La phase de rendu peut s’exécuter plusieurs fois et doit rester pure ; la phase d’application a lieu une seule fois, de manière synchrone, et c’est là que les effets sont déclenchés.
    • Les dispatchers expliquent à la fois la séparation entre le montage et la mise à jour, ainsi que l’erreur qui se produit lorsque un hook est appelé en dehors du processus de rendu.
    • Savoir où chaque hook stocke ses données et quand son traitement s’exécute facilite le choix du bon moment pour déclencher des effets, permet de conserver l’efficacité de la mémorisation et aide à comprendre le contexte ainsi que les mises à jour concurrentes.

    Lectures complémentaires

  • Un hook useFetch minimaliste : quand on peut ignorer React Query et ses limites — Créez un petit hook useFetch en mémoire tampon utilisant AbortController, une expiration TTL ainsi qu’un companion useMutation, et découvrez précisément quelles fonctionnalités de React Query vous renoncez.