Accueil / Articles

Créez un useState de jouet pour comprendre les snapshots, les files d’attente et les effets

Apprenez comment se comportent les hooks React en créant un petit useState, puis utilisez ce modèle pour expliquer les files d’actualisation, les snapshots obsolètes, les closures, le nettoyage des effets, les refs et le regroupement des opérations.

Éditeur : REACTAPP.TOP14 min de lecture3107 mots

Sources: React Hooks Internally: How `useState`, `useEffect`& Other Hooks Actually Work

Une seule ligne de code hook cache une quantité surprenante de mécanismes :

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

React doit décider où est stocké count, comment il survit à chaque appel, quel état cible un donné setter, pourquoi les appels de hook ne peuvent pas être conditionnels, et comment les effets suivent les dépendances. Ce guide construit un petit modèle fonctionnel des hooks et l’utilise pour expliquer les comportements qui posent problème au quotidien : valeurs obsolètes après setState, mises à jour fonctionnelles, nettoyage des effets et regroupement d’appels.

L’état ne vit pas dans la fonction

Prenons le composant à état le plus simple possible :

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

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

Il est tentant de penser que count se trouve à l’intérieur de Counter, mais React appelle simplement la fonction à nouveau à chaque rendu :

Render 1
Counter()
count = 0

Render 2
Counter()
count = 1

Render 3
Counter()
count = 2

Les variables locales disparaissent lorsque une fonction retourne, c’est pourquoi React conserve l’état des hooks en dehors du composant, attaché à son Fiber, et restitue la valeur stockée à chaque rendu.

Le Fiber contient une séquence de hooks

De manière simplifiée, la relation s’exprime ainsi :

Component
    │
    ▼
  Fiber
    │
    ▼
Hook data
    │
 ┌──┼───────────────┐
 ▼  ▼               ▼
State Ref         Effect

Un Fiber est une unité de travail de React, créée tant pour les composants que pour les éléments hôtes. Pour un composant fonctionnel, il contient les données des hooks, conceptuellement sous forme de chaîne :

Counter Fiber
      │
      ▼
   Hook #1
      │
      ▼
   Hook #2
      │
      ▼
   Hook #3

L’implémentation réelle comporte bien plus de champs, mais cette représentation suffit pour comprendre le comportement de base.

Les hooks sont associés par ordre d’appel

Voici un composant contenant trois hooks :

function Component() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("Salim");

  useEffect(() => {
    console.log(count);
  }, [count]);

  return ...;
}

React ne possède pas de noms pour ces hooks ; il ne connaît que « premier », « deuxième » et « troisième », en supposant qu’ils apparaissent dans cet ordre à chaque rendu :

Fiber
 │
 ├── Hook #1 → useState
 │
 ├── Hook #2 → useState
 │
 └── Hook #3 → useEffect

Cette hypothèse constitue toute la base des Règles des hooks.

Quel est le problème avec un hook conditionnel

Considérons maintenant un hook caché derrière une condition :

function Component({ loggedIn }) {

  if (loggedIn) {
    useState("Salim"); // ❌
  }

  useState(0);

  return ...;
}

Lors d’une rendu où l’utilisateur est connecté, deux hooks sont appelés :

loggedIn = true

Hook #1 → name
Hook #2 → count

Si loggedIn devient faux par la suite, un seul appel a lieu :

loggedIn = false

Hook #1 → count

La variable utilisée pour stocker le nom reçoit maintenant l’appel de comptage, ce qui fait que l’état est associé au mauvais hook :

Before:
1 → name
2 → count

After:
1 → count

Ainsi, l’ordre des hooks ne doit jamais varier entre les rendus, ce qui exclut les appels en de tels endroits :

if / else
loops
nested functions
event handlers

Placez-les au niveau le plus élevé des composants et des hooks personnalisés ; le plugin de vérification des hooks React impose cela.

Un useState simplifié en vingt lignes

La création d’une version minimale rend le mécanisme concret. Le schéma ci-dessous conserve un tableau de slots d’ancrage ainsi qu’un curseur. Chaque appel à useState réserve le slot situé au curseur, l’initialise uniquement s’il est vide, retourne un setteur lié à cet index et fait avancer le curseur :

let hooks = [];
let currentHook = 0;

function useState(initialValue) {
  const index = currentHook;

  if (hooks[index] === undefined) {
    hooks[index] = {
      state: initialValue
    };
  }

  function setState(value) {
    hooks[index].state =
      typeof value === "function"
        ? value(hooks[index].state)
        : value;

    render();
  }

  const state = hooks[index].state;

  currentHook++;

  return [state, setState];
}

Deux choses sont implicites mais non montrées : render() doit réinitialiser currentHook à 0 avant d’appeler à nouveau la composante, et il doit relancer cette dernière. Pour une composante de ce type :

function Counter() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("Salim");

  return ...;
}

le stockage finit par ressembler à ceci :

hooks[0]
  state = 0

hooks[1]
  state = "Salim"

Lors du prochain rendu, chaque appel retrouve son slot uniquement en se basant sur sa position.

Où s’arrête le modèle simplifié

Real React ajoute des objets de hook sur les Fibers, des files d’attente de mise à jour, un planificateur avec des voies par priorité, des dispatchers de montage et de mise à jour, ainsi que de la logique de conciliation et d’engagement ; il n’y a pas de tableau global. L’idée fondamentale reste la même :

Stable Hook order
       +
React-managed Hook state
       +
Component Fiber

Comment fonctionne useState dans React

Pour cette appel :

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

React a besoin de deux éléments par hook d’état :

State
+
Update queue

En termes visuels, le hook conserve sa valeur actuelle dans memoizedState ainsi qu’une file d’attente des mises à jour en attente :

Hook
 ├── memoizedState → 0
 │
 └── queue
      └── pending updates

Le premier rendu : montage

Avec ce composant :

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

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

le rendu initial crée le hook, stocke la valeur initiale, configure une file d’attente vide et renvoie le couple correspondant :

Component renders
      ↓
useState(0)
      ↓
Create Hook
      ↓
Store initial state
      ↓
Create update queue
      ↓
Return [0, setCount]

La Fiber ressemble maintenant à peu près à ceci, et cette phase est appelée montage :

Fiber
 │
 └── Hook
      ├── state: 0
      └── queue

Rendus ultérieurs : mise à jour

Supposons qu’un gestionnaire d’événement appelle :

setCount(5);

React planifie une mise à jour. Lors du prochain rendu, useState ne crée rien ; il localise le hook existant, applique les mises à jour en file d’attente et renvoie le résultat :

Component renders
      ↓
useState(0)
      ↓
React finds existing Hook
      ↓
Process queued updates
      ↓
New state = 5
      ↓
Return [5, setCount]

Le 0 que vous passez est donc une valeur initiale, et non un réinitialisation. Cela n’a d’importance que lors de la création initiale du hook.

setState est une demande, pas une affectation

Une appelation de ce type :

setCount(5);

n’implique pas « set count à 5 maintenant ». Elle enregistre une demande que React traite selon son propre calendrier :

setCount(5)
     ↓
Create update
     ↓
Add update to queue
     ↓
Schedule React work
     ↓
Render component
     ↓
Process update queue
     ↓
Calculate new state
     ↓
Reconcile UI
     ↓
Commit changes

Les mises à jour s’enchaînent dans l’ordre

Considérez trois mises à jour fonctionnelles consécutives :

setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);

React les garde en file d’attente, en partant de la valeur actuelle :

Update Queue

c => c + 1
c => c + 1
c => c + 1

If the current state is: 0

et les applique une après l’autre :

0 → 1
1 → 2
2 → 3

Le résultat est 3, c’est pourquoi les mises à jour fonctionnelles conviennent aux mises à jour qui dépendent de la valeur précédente.

Pourquoi le passage d’une valeur se comporte différemment

Comparez la version qui passe des valeurs :

setCount(count + 1);
setCount(count + 1);
setCount(count + 1);

Si le rendu actuel a count égal à 0, les trois expressions sont évaluées avant que toute mise à jour ne soit traitée, donc elles donnent :

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

La valeur finale est 1. Avec des fonctions de mise à jour :

setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);

chaque fonction reçoit le résultat de la précédente, ce qui donne 0, 1, 2 et finalement 3.

L’état est un instantané d’un rendu

Cet gestionnaire surprend de nombreux développeurs :

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

  function handleClick() {
    setCount(1);

    console.log(count);
  }

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

Le journal affiche 0, et non 1. count est une constante appartenant au rendu qui a créé le gestionnaire ; appeler la fonction de mise à jour ne la modifie pas :

Current render
count = 0
      ↓
setCount(1)
      ↓
Update scheduled
      ↓
Current render still has count = 0
      ↓
Next render gets count = 1

Les closures expliquent l’instantané

Ce comportement relève simplement des sématiques de fermeture de JavaScript. Regardez :

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

  function logCount() {
    console.log(count);
  }

  return <button onClick={logCount}>Log</button>;
}

Chaque rendu définit un nouveau logCount, et chacun capture la valeur de count de son propre rendu :

Render #1
count = 0
 └── logCount closes over 0

Render #2
count = 1
 └── logCount closes over 1

Un ancien callback voit toujours les valeurs de son propre rendu, ce qui est à l’origine de la plupart des bugs liés aux valeurs obsolètes.

Comment useEffect mémorise-t-il ses dépendances

Prenons un effet de base :

useEffect(() => {
  console.log(count);
}, [count]);

React doit tenir compte de trois éléments pour cela :

Effect function
Dependencies
Cleanup function

En interne, ceux-ci sont stockés dans le hook de l’effet, sous des noms similaires :

Hook
 ├── create → effect function
 ├── deps   → [count]
 └── destroy → cleanup

À chaque rendu, React compare le nouveau tableau de dépendances avec celui qui a été stocké.

Comparaison des dépendances

Avec le même effet :

useEffect(() => {
  console.log(count);
}, [count]);

le premier rendu enregistre :

count = 0
deps = [0]

et le rendu suivant produit :

count = 1
deps = [1]

React compare les tableaux élément par élément :

Previous: [0]
New:      [1]

Changed
 ↓
Effect needs to run

Quelque chose a changé, donc l’effet s’exécute. Si un rendu ultérieur produit les mêmes valeurs :

Previous: [1]
New:      [1]

Same
 ↓
Effect can be skipped

l’effet est ignoré. Chaque élément est comparé avec Object.is, ce qui signifie qu’un objet ou un tableau recréé lors du rendu est considéré comme modifié à chaque fois.

Nettoyage

Un effet peut retourner une fonction qui annule son action :

useEffect(() => {
  const id = setInterval(() => {
    console.log("tick");
  }, 1000);

  return () => {
    clearInterval(id);
  };
}, []);

Cette fonction retournée constitue le nettoyage. Le cycle de vie de l’intervalle se présente comme suit :

Effect setup
     ↓
Interval created
     ↓
...
Cleanup
     ↓
Interval removed

Lorsque les dépendances peuvent changer, le nettoyage devient encore plus important :

useEffect(() => {
  subscribe(userId);

  return () => {
    unsubscribe(userId);
  };
}, [userId]);

À chaque modification de userId, React détruit l’effet précédent avant d’en démarrer un nouveau, et il exécute le nettoyage final lorsque le composant est démonté :

Old effect
    ↓
Cleanup
    ↓
New effect

Les effets se synchronisent, ce ne sont pas des méthodes de cycle de vie

Une astuce courante consiste à traiter useEffect avec un tableau vide comme s’il s’agissait de componentDidMount. Une façon plus utile de le concevoir est de voir dans cet effet un mécanisme permettant à React de rester en synchronisation avec un système externe, tel que :

Network requests
Timers
Subscriptions
Browser APIs
DOM APIs
Third-party libraries
WebSocket connections

Par exemple, une connexion de chat :

useEffect(() => {
  const connection = connect(roomId);

  return () => {
    connection.disconnect();
  };
}, [roomId]);

Lorsque roomId change, l’effet quitte la vieille salle et rejoint la nouvelle, de sorte que la connexion correspond toujours aux props actuels.

useRef : un objet stable

const ref = useRef(0);

React renvoie toujours le même objet à chaque rendu :

Render #1
ref → Object A

Render #2
ref → Object A

Render #3
ref → Object A

Par conséquent, effectuer une assignation dessus :

ref.current = 100;

ne provoque pas de rendu, contrairement à une mise à jour d’état :

setCount(100);

qui en programme un. En résumé, la différence est la suivante :

useState
 ├── React-managed state
 ├── update schedules render
 └── used for UI state

useRef
 ├── stable object
 ├── mutable .current
 └── changing .current doesn't schedule render

Une utilisation typique consiste à stocker un identifiant de timer :

function Timer() {
  const timerRef = useRef(null);

  function start() {
    timerRef.current = setInterval(() => {
      console.log("tick");
    }, 1000);
  }

  return <button onClick={start}>Start</button>;
}

L’ID doit persister mais ne jamais apparaître à l’écran, c’est la tâche typique d’une référence.

useMemo et useCallback

Un calcul mémorisé se présente comme ceci :

const result = useMemo(
  () => expensiveCalculation(data),
  [data]
);

Le hook stocke conceptuellement le dernier résultat ainsi que les dépendances utilisées pour le calculer :

Hook
 ├── memoizedValue
 └── dependencies

Lors du premier rendu, le calcul est exécuté :

data = A

calculate(A)
 ↓
result = X

Si le rendu suivant reçoit les mêmes données :

data = A

les dépendances restent inchangées :

A → A

donc le résultat stocké X est retourné. Lorsque data devient B, le calcul est exécuté à nouveau.

Considérez cela comme une optimisation, et non une promesse : React peut supprimer le cache, il faut donc que le code reste correct même en cas de recalculation. Utilisez-le pour des opérations coûteuses ou lorsque une référence stable est importante, et non par défaut.

useCallback applique la même idée aux fonctions :

const handleClick = useCallback(() => {
  doSomething(id);
}, [id]);

Il stocke la fonction et ses dépendances :

Hook
 ├── memoized function
 └── dependencies

Les dépendances inchangées renvoient la même référence de fonction ; celles qui ont changé renvoient une nouvelle référence. Cela est particulièrement important lorsque le callback est dirigé vers un enfant enveloppé dans React.memo, car une nouvelle référence annulerait l’effet de la mémorisation :

const Child = React.memo(ChildComponent);

Les hooks personnalisés ne sont que des fonctions

Voici un petit hook personnalisé :

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

  function increment() {
    setCount(c => c + 1);
  }

  return {
    count,
    increment
  };
}

utilisé par un composant :

function App() {
  const { count, increment } = useCounter();

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

Un hook personnalisé n’est pas un composant distinct. C’est une simple fonction construite à partir d’autres hooks, et son useState est associé au composant qui s’affiche actuellement :

App Fiber
   ↓
useCounter()
   ↓
useState()
   ↓
Hook state

Deux composants appelant useCounter obtiennent donc deux compteurs indépendants.

Dispatchers : une API, deux comportements

Lorsque vous appelez useState, la fonction publique redirige vers le dispatcheur que React a installé pour la rendu actuel :

useState()
   ↓
Current Dispatcher
   ↓
mountState() / updateState()

Lors du premier rendu, il s’agit de l’implémentation de montage :

Mount
 ↓
mountState()

et lors des rendus ultérieurs, de l’implémentation de mise à jour :

Update
 ↓
updateState()

Montage versus mise à jour pour un hook

Pour cette appel :

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

le montage crée les structures :

Create Hook
     ↓
Store initial state
     ↓
Create update queue

tandis que la mise à jour les réutilise :

Find existing Hook
     ↓
Process pending updates
     ↓
Calculate new state
     ↓
Return state + dispatcher

En résumé :

FIRST RENDER
     ↓
Mount Hooks

SUBSEQUENT RENDERS
     ↓
Update Hooks

Phase de rendu et phase d’application

Les hooks ont le plus de sens dans le cadre du modèle de rendu en deux phases de React :

State / Props Update
        ↓
     Render Phase
        ↓
   Reconciliation
        ↓
     Commit Phase
        ↓
       DOM

Pendant le rendu, React appelle les composants et calcule l’interface utilisateur suivante :

Components execute
Hooks are processed
New UI is calculated

Pendant la phase d’application, il applique le résultat obtenu :

DOM mutations happen
Refs are updated
Layout effects are handled

React moderne peut suspendre, redémarrer ou ignorer le travail de rendu, tandis que l’envoi des modifications s’effectue toujours complètement, c’est pourquoi la logique de rendu doit être pure.

Garantir une logique de rendu pure

Évitez les effets secondaires directement dans le corps du composant :

function Component() {
  fetch("/api/users"); // ❌

  return <div>Hello</div>;
}

Le rendu peut s’exécuter à plusieurs reprises ou être ignoré avant l’envoi des modifications, ce qui pourrait faire exécuter cette requête de nombreuses fois. Utilisez plutôt l’un de ces solutions :

Event handler
useEffect
Framework data-loading mechanism

Le rendu ne doit calculer l’interface utilisateur qu’à partir des entrées :

UI = f(props, state)

Assemblage : un clic, du début à la fin

Voici un composant utilisant l’état et un effet :

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

  useEffect(() => {
    document.title = `Count: ${count}`;
  }, [count]);

  return (
    <button onClick={() => setCount(c => c + 1)}>
      {count}
    </button>
  );
}

Un clic traverse toutes les étapes décrites ci-dessus :

Click
 ↓
setCount()
 ↓
Update added to Hook queue
 ↓
React schedules work
 ↓
Counter renders
 ↓
useState retrieves/processes state
 ↓
useEffect checks dependencies
 ↓
React reconciles new UI
 ↓
Commit DOM changes
 ↓
Effect runs

Groupement et raison pour laquelle le DOM n’est pas mis à jour immédiatement

Un gestionnaire qui modifie plusieurs éléments d’état :

function handleClick() {
  setCount(c => c + 1);
  setName("Salim");
  setVisible(true);
}

Cela ne provoque pas trois rendus distincts. Depuis React 18, les mises à jour sont agrégées automatiquement, y compris celles provenant de timeouts et de promesses :

setCount()
setName()
setVisible()
      ↓
    Batch
      ↓
   Render
      ↓
   Commit

plutôt que :

Update → Render → Commit
Update → Render → Commit
Update → Render → Commit

L’agrégation est en soi un moyen d’éviter des tâches inutiles.

La même logique explique pourquoi une appelation comme :

setCount(10);

ne signifie pas immédiatement :

state = 10
DOM = 10

Au lieu de cela, elle suit le flux du pipeline :

setCount(10)
     ↓
Schedule update
     ↓
React render
     ↓
Reconciliation
     ↓
Commit
     ↓
DOM update

Le report donne à React la possibilité d’agréger, de prioriser et d’éviter des tâches redondantes ; flushSync de react-dom force une mise à jour synchrone lorsque c’est vraiment nécessaire.

Le modèle dans son ensemble

Tout ce qui précède s’inscrit dans un même schéma, depuis le composant jusqu’aux pixels à l’écran :

                                    FUNCTION COMPONENT
                          │
                          ▼
                        Fiber
                          │
                          ▼
                    Hook sequence
                          │
          ┌───────────────┼───────────────┐
          ▼               ▼               ▼
       useState         useRef         useEffect
          │               │               │
          ▼               ▼               ▼
    Update Queue     Stable Object     Dependencies
          │
          ▼
      setState()
          │
          ▼
    Schedule Update
          │
          ▼
        Render
          │
          ▼
    Reconciliation
          │
          ▼
        Commit
          │
          ▼
         DOM
          │
          ▼
       Browser
          │
          ▼
        Pixels

Points clés

  • L’état des hooks persiste dans le Fiber du composant et est associé à chaque appel en fonction de sa position, c’est pourquoi l’ordre des hooks ne doit jamais changer.
  • setState inscrit une mise à jour dans la file d’attente ; la nouvelle valeur apparaît lors du prochain rendu, tandis que le rendu actuel conserve son snapshot.
  • Utilisez des mises à jour fonctionnelles chaque fois qu’une mise à jour dépend de la valeur précédente.
  • Les effets se synchronisent avec des systèmes externes ; les dépendances sont comparées à l’aide de Object.is, et le nettoyage s’exécute avant une nouvelle exécution ainsi que lors du démontage.
  • Les refs sont des boîtes mutables stables qui ne déclenchent jamais de rendus ; la mémorisation est une optimisation, pas une garantie.
  • Les hooks personnalisés partagent de la logique, mais pas l’état, et le rendu doit rester pur car React peut le répéter ou l’ignorer.