Synchroniser grâce aux Effets

Certains composants ont besoin de se synchroniser avec des systèmes tiers. Par exemple, vous pourriez vouloir contrôler un composant non-React sur la base d’un état React, mettre en place une connexion à un serveur, ou envoyer des données analytiques lorsqu’un composant apparaît à l’écran. Les Effets vous permettent d’exécuter du code après le rendu, de façon à synchroniser votre composant avec un système extérieur à React.

Vous allez apprendre

  • Ce que sont les Effets
  • En quoi les Effets diffèrent des événements
  • Comment déclarer un Effet dans votre composant
  • Comment éviter de ré-exécuter inutilement un Effet
  • Pourquoi les Effets sont exécutés deux fois en développement, et comment les corriger

Qu’est-ce qu’un Effet, et en quoi ça diffère d’un événement ?

Avant d’étudier les Effets, vous devez être à l’aise avec deux types de code dans les composants React :

  • Le code de rendu (présenté dans Décrire l’UI) vit au niveau racine de votre composant. C’est là que vous récupérez les props et l’état, les transformez et renvoyez du JSX décrivant ce que vous voulez voir à l’écran. Le code de rendu doit être pur. Comme une formule mathématique, il doit se contenter de calculer le résultat, un point c’est tout.

  • Les gestionnaires d’événements (présentés dans Ajouter de l’interactivité) sont des fonctions locales à vos composants qui font des choses, plutôt que juste calculer des résultats. Un gestionnaire d’événement pourrait mettre à jour un champ de saisie, envoyer une requête HTTP POST pour acheter un produit, ou emmener l’utilisateur vers un nouvel écran. Les gestionnaires d’événements déclenchent des « effets de bord » (ils modifient l’état du programme) en réponse à une action utilisateur spécifique (par exemple un clic sur un bouton ou une saisie clavier).

Mais parfois, ça ne suffit pas. Imaginez un composant ChatRoom qui doit se connecter à un serveur de discussion dès qu’il devient visible à l’écran. La connexion au serveur ne constitue pas un calcul pur (c’est un effet de bord), elle ne doit donc pas survenir pendant le rendu. Et pourtant, il n’existe pas d’événement particulier (tel qu’un clic) pour signifier que ChatRoom devient visible.

Les Effets vous permettent de spécifier des effets de bord causés par le rendu lui-même, plutôt que par un événement particulier. Envoyer un message dans la discussion est un événement, parce que c’est directement lié au fait que l’utilisateur a cliqué sur un bouton précis. En revanche, mettre en place la connexion au serveur est un Effet parce que ça doit se produire quelle que soit l’interaction qui a entraîné l’affichage du composant. Les Effets sont exécuté à la fin de la phase de commit, après que l’écran a été mis à jour. C’est le bon moment pour synchroniser les composants React avec des systèmes extérieurs (comme par exemple le réseau ou une bibliothèque tierce).

Remarque

Dans cette page, le terme « Effet » avec une initiale majuscule fait référence à la définition ci-dessus, spécifique à React : un effet de bord déclenché par le rendu. Pour parler du concept plus général de programmation, nous utilisons le terme « effet de bord ».

Vous n’avez pas forcément besoin d’un Effet

Ne vous précipitez pas pour ajouter des Effets à vos composants. Gardez à l’esprit que les Effets sont généralement utilisés pour « sortir » de votre code React et vous synchroniser avec un système extérieur. Ça inclut les API du navigateur, des widgets tiers, le réseau, etc. Si votre Effet se contente d’ajuster des variables d’état sur la base d’autres éléments d’état, vous n’avez pas forcément besoin d’un Effet.

Comment écrire un Effect

Pour écrire un Effet, suivez ces trois étapes :

  1. Déclarez un Effet. Par défaut, votre Effet s’exécutera après chaque rendu.
  2. Spécifiez les dépendances de l’Effet. La plupart des Effets ne devraient se ré-exécuter que si besoin plutôt qu’après chaque rendu. Par exemple, une animation de fondu entrant ne devrait se déclencher que pour l’apparition initiale. La connexion et la déconnexion à un forum de discussion ne devraient survenir que quand le composant apparaît, disparaît, ou change de canal. Vous apprendrez à contrôler cet aspect en spécifiant des dépendances.
  3. Ajoutez du code de nettoyage si besoin. Certains Effets ont besoin de décrire comment les arrêter, les annuler, ou nettoyer après eux de façon générale. Par exemple, une connexion implique une déconnexion, un abonnement suppose un désabonnement, et un chargement réseau aura besoin de pouvoir être annulé ou ignoré. Vous apprendrez comment décrire ça en renvoyant une fonction de nettoyage.

Explorons maintenant chaque étape en détail.

Étape 1 : déclarez un Effet

Pour déclarer un Effet dans votre composant, importez le Hook useEffect depuis React :

import { useEffect } from 'react';

Ensuite, appelez-le au niveau racine de votre composant et placez le code adéquat dans votre Effet :

function MyComponent() {
useEffect(() => {
// Du code ici qui s’exécutera après *chaque* rendu
});
return <div />;
}

Chaque fois que le composant calculera son rendu, React mettra l’affichage à jour et ensuite exécutera le code au sein du useEffect. En d’autres termes, useEffect « retarde » l’exécution de ce bout de code jusqu’à ce que le résultat du rendu se reflète à l’écran.

Voyons comment vous pouvez utiliser un Effet pour vous synchroniser avec un système extérieur. Prenons un composant React <VideoPlayer>. Ce serait chouette de pouvoir contrôler son état de lecture (en cours ou en pause) en lui passant une prop isPlaying :

<VideoPlayer isPlaying={isPlaying} />

Votre composant personnalisé VideoPlayer utilise la balise native <video> du navigateur :

function VideoPlayer({ src, isPlaying }) {
// TODO: se servir de isPlaying
return <video src={src} />;
}

Toutefois, la balise <video> du navigateur n’a pas d’attribut isPlaying. Le seul moyen d’en contrôler la lecture consiste à appeler manuellement les méthodes play() et pause() de l’élément du DOM. Vous devez vous synchroniser avec la valeur de la prop isPlaying, qui vous indique si la vidéo devrait être en cours de lecture, en appelant play() et pause() aux moments adéquats.

Nous allons d’abord avoir besoin d’une ref vers le nœud <video> du DOM.

Vous pourriez être tenté·e d’appeler directement play() ou pause() au sein du rendu, mais ce serait une erreur :

import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  if (isPlaying) {
    // Ces appels sont interdits pendant le rendu.
    ref.current.play();
  } else {
    // En plus, ça plante.
    ref.current.pause();
  }

  return <video ref={ref} src={src} loop playsInline />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  return (
    <>
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Lecture'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

Ce code est incorrect parce qu’il essaie de manipuler le DOM pendant le rendu. Dans React, le rendu doit être un calcul pur de JSX et ne devrait pas contenir d’effets de bord tels qu’une manipulation du DOM.

Qui plus est, quand VideoPlayer est appelé pour la première fois, son DOM n’existe pas encore ! Il n’y a pas encore de nœud DOM sur lequel appeler play() ou pause(), parce que React ne saura quel DOM créer qu’une fois que vous aurez renvoyé le JSX.

La solution consiste à enrober l’effet de bord avec un useEffect pour le sortir du calcul de rendu :

import { useEffect, useRef } from 'react';

function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);

useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
});

return <video ref={ref} src={src} loop playsInline />;
}

En enrobant la mise à jour du DOM avec un Effet, vous laissez React mettre à jour l’écran d’abord. Ensuite votre Effet s’exécute.

Quand votre composant VideoPlayer fait son rendu (que ce soit la première fois ou non), plusieurs choses se passent. Pour commencer, React va mettre l’écran à jour, garantissant ainsi une balise <video> dans le DOM avec les bons attributs. Ensuite, React va exécuter votre Effet. Pour finir, votre Effet va appeler play() ou pause() selon la valeur de isPlaying.

Appuyez sur Lecture / Pause plusieurs fois pour vérifier que le lecteur vidéo reste bien synchronisé avec la valeur de isPlaying :

import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      ref.current.play();
    } else {
      ref.current.pause();
    }
  });

  return <video ref={ref} src={src} loop playsInline />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  return (
    <>
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Lecture'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

Dans cet exemple, le « système extérieur » que vous avez synchronisé avec l’état React, c’est l’API média du navigateur. Vous pouvez utiliser une approche similaire pour enrober du code historique non-React (tel que des plugins jQuery) pour en faire des composants React déclaratifs.

Remarquez qu’en pratique le pilotage d’un lecteur vidéo est nettement plus complexe. L’appel à play() pourrait échouer, l’utilisateur pourrait lancer ou stopper la lecture au moyen de contrôles natifs du navigateur, etc. Cet exemple est très simplifié et incomplet.

Piège

Par défaut, les Effets s’exécutent après chaque rendu. C’est pourquoi le code suivant produirait une boucle infinie :

const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1);
});

Les Effets s’exécutent en conséquence d’un rendu. Modifier l’état déclenche un rendu. Le modifier au sein d’un Effet, c’est un peu comme brancher une multiprise sur elle-même. L’Effet s’exécute, modifie l’état, ce qui entraîne un nouveau rendu, ce qui déclenche une nouvelle exécution de l’Effet, qui modifie à nouveau l’état, entraînant un nouveau rendu, et ainsi de suite.

Les Effets ne devraient normalement synchroniser vos composants qu’avec des systèmes extérieurs. S’il n’y a pas de système extérieur et que vous voulez seulement ajuster un bout d’état sur base d’un autre, vous n’avez pas forcément besoin d’un Effet.

Étape 2 : spécifiez les dépendances de l’Effet

Par défaut, les Effets s’exécutent après chaque rendu. Souvent pourtant, ce n’est pas ce que vous voulez :

  • Parfois, c’est lent. La synchronisation avec un système extérieur n’est pas toujours instantanée, aussi vous pourriez vouloir l’éviter si elle est superflue. Par exemple, vous ne souhaitez pas vous reconnecter au serveur de discussion à chaque frappe clavier.
  • Parfois, c’est incorrect. Par exemple, vous ne voulez pas déclencher une animation de fondu entrant à chaque frappe clavier. L’animation ne devrait se dérouler qu’une seule fois, après que le composant apparaît.

Pour mettre ce problème en évidence, revoici l’exemple précédent avec quelques appels à console.log en plus, et un champ de saisie textuelle qui met à jour l’état du parent. Voyez comme la saisie entraîne la ré-exécution de l’Effet :

import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      console.log('Appel à video.play()');
      ref.current.play();
    } else {
      console.log('Appel à video.pause()');
      ref.current.pause();
    }
  });

  return <video ref={ref} src={src} loop playsInline />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  const [text, setText] = useState('');
  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Lecture'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

Vous pouvez dire à React de sauter les ré-exécutions superflues de l’Effet en fournissant un tableau de dépendances comme second argument lors de l’appel à useEffect. Commencez par ajouter un tableau vide [] dans l’exemple précédent, à la ligne 14 :

useEffect(() => {
// ...
}, []);

Vous devriez voir une erreur qui dit React Hook useEffect has a missing dependency: 'isPlaying' :

import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      console.log('Appel à video.play()');
      ref.current.play();
    } else {
      console.log('Appel à video.pause()');
      ref.current.pause();
    }
  }, []); // Là, on va avoir un problème

  return <video ref={ref} src={src} loop playsInline />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  const [text, setText] = useState('');
  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Lecture'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

Le souci vient du fait que le code au sein de notre Effet dépend de la prop isPlaying pour décider quoi faire, mais cette dépendance n’est pas explicitement déclarée. Pour corriger le problème, ajoutez isPlaying dans le tableau des dépendances :

useEffect(() => {
if (isPlaying) { // On l’utilise ici...
// ...
} else {
// ...
}
}, [isPlaying]); // ...donc on doit la déclarer ici !

À présent que toutes les dépendances sont déclarées, il n’y a plus d’erreur. En spécifiant [isPlaying] comme tableau de dépendances, nous disons à React qu’il devrait éviter de ré-exécuter votre Effet si isPlaying n’a pas changé depuis le rendu précédent. Grâce à cet ajustement, la saisie dans le champ n’entraîne plus la ré-exécution de l’Effet, mais activer Lecture / Pause si :

import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      console.log('Appel à video.play()');
      ref.current.play();
    } else {
      console.log('Appel à video.pause()');
      ref.current.pause();
    }
  }, [isPlaying]);

  return <video ref={ref} src={src} loop playsInline />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  const [text, setText] = useState('');
  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Lecture'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

Le tableau de dépendances peut contenir plusieurs dépendances. React ne sautera la ré-exécution de l’Effet que si toutes les dépendances que vous avez spécifiées sont exactement identiques à leurs valeurs du rendu précédent. React compare les valeurs des dépendances en utilisant le comparateur Object.is. Consultez la référence de useEffect pour davantage de détails.

Remarquez que vous ne pouvez pas « choisir » vos dépendances. Vous aurez une erreur de linting si les dépendances que vous spécifiez ne correspondent pas à celles que React attend, sur base de l’analyse du code au sein de votre Effet. Ça aide à repérer pas mal de bugs dans votre code. Si vous voulez empêcher la ré-exécution d’un bout de code, modifiez le code de l’Effet lui-même pour ne pas « nécessiter» cette dépendance.

Piège

Le comportement n’est pas le même entre une absence du tableau de dépendances, et un tableau de dépendances vide [] :

useEffect(() => {
// S’exécute après chaque rendu
});

useEffect(() => {
// S’exécute uniquement au montage (apparition du composant)
}, []);

useEffect(() => {
// S’exécute au montage *mais aussi* si a ou b changent depuis le rendu précédent
}, [a, b]);

Nous verrons de plus près ce que « montage » signifie lors de la prochaine étape.

En détail

Pourquoi n’a-t-on pas ajouté la ref au tableau de dépendances ?

Cet Effet utilise isPlaying mais aussi ref, pourtant nous avons seulement déclaré isPlaying comme dépendance :

function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
}, [isPlaying]);

C’est parce que l’objet ref a une identité stable : React garantit que vous aurez toujours le même objet comme résultat du même appel useRef d’un rendu à l’autre. Il ne changera jamais, et donc n’entraînera jamais par lui-même la ré-exécution de l’Effet. Du coup, l’inclure ou pas ne changera rien. Vous pouvez effectivement l’inclure :

function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
}, [isPlaying, ref]);

Les fonctions set renvoyées par useState ont aussi une identité stable, et peuvent donc elles aussi être omises des dépendances. Si le linter vous permet d’omettre une dépendance sans déclencher d’erreurs, c’est que cette omission est fiable.

L’omission de dépendances à identité stable ne marche cependant que si le linter peut « voir » que celle-ci est stable. Par exemple, si ref était passée depuis le composant parent, il vous faudrait l’ajouter au tableau de dépendances. Ceci dit, ce serait une bonne chose parce que vous ne pouvez pas savoir si le composant parent vous passera toujours la même ref, ou basculera entre plusieurs refs selon une condition interne. Du coup votre Effet dépendrait bien de la ref qui vous est passée.

Étape 3 : ajoutez du code de nettoyage si besoin

Prenons un autre exemple. Vous écrivez un composant ChatRoom qui a besoin de se connecter à un serveur de discussion lorsqu’il apparaît. Vous disposez d’une API createConnection() qui renvoie un objet avec des méthodes connect() et disconnect(). Comment garder votre composant connecté pendant qu’il est affiché à l’utilisateur ?

Commencez par écrire le code de l’Effet :

useEffect(() => {
const connection = createConnection();
connection.connect();
});

Ce serait toutefois beaucoup trop lent de vous (re)connecter après chaque rendu ; vous spécifiez donc un tableau de dépendances :

useEffect(() => {
const connection = createConnection();
connection.connect();
}, []);

Le code dans l’Effet n’utilise ni props ni état, donc votre tableau de dépendances est vide []. Vous indiquez ici à React qu’il ne faut exécuter le code que lors du « montage » du composant, c’est-à-dire lorsque celui-ci apparaît à l’écran pour la première fois.

Essayons d’exécuter ce code :

import { useEffect } from 'react';
import { createConnection } from './chat.js';

export default function ChatRoom() {
  useEffect(() => {
    const connection = createConnection();
    connection.connect();
  }, []);
  return <h1>Bienvenue dans la discussion !</h1>;
}

Cet Effet n’est exécuté qu’au montage, vous vous attendez donc sans doute à ce que "✅ Connexion..." ne soit logué qu’une fois en console. Et pourtant, en vérifiant la console, vous voyez deux occurrences de "✅ Connexion...". Qu’est-ce qui se passe ?

Imaginez que le composant ChatRoom fasse partie d’une appli plus grande avec de nombreux écrans distincts. L’utilisateur démarre dans la page ChatRoom. Le composant est monté puis appelle connection.connect(). Supposez maintenant que l’utilisateur navigue vers un autre écran—par exemple, la page des Paramètres. Le composant ChatRoom est démonté. Au final, l’utilisateur navigue en arrière et le composant ChatRoom est monté à nouveau. Ça mettrait en place une deuxième connexion—sauf que la première n’a jamais été nettoyée ! Au fil de la navigation de l’utilisateur au sein de l’appli, les connexions s’accumuleraient.

Des bugs de ce genre sont difficiles à repérer sans avoir recours à des tests manuels étendus. Pour vous aider à les repérer plus vite, en mode développement React remonte chaque composant une fois immédiatement après leur montage initial.

En voyant deux fois le message "✅ Connexion...", ça vous aide à remarquer le vrai problème : votre code ne ferme pas la connexion au démontage du composant.

Pour corriger ça, renvoyez une fonction de nettoyage depuis votre Effet :

useEffect(() => {
const connection = createConnection();
connection.connect();
return () => {
connection.disconnect();
};
}, []);

React appellera cette fonction de nettoyage avant chaque ré-exécution de votre Effet, ainsi qu’une dernière fois lorsque le composant est démonté (lorqu’il est retiré). Voyons ce qui se passe à présent que nous avons implémenté la fonction de nettoyage :

import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';

export default function ChatRoom() {
  useEffect(() => {
    const connection = createConnection();
    connection.connect();
    return () => connection.disconnect();
  }, []);
  return <h1>Bienvenue dans la discussion !</h1>;
}

Vous voyez maintenant trois logs dans la console en mode développement :

  1. "✅ Connexion..."
  2. "❌ Déconnecté."
  3. "✅ Connexion..."

C’est le comportement correct en développement. En remontant votre composant, React vérifie que le quitter puis revenir ne crée pas de problèmes. Se déconnecter puis se reconnecter est exactement ce qu’on souhaite ! Lorsque vous implémentez votre nettoyage correctement, il ne devrait y avoir aucune différence visible entre l’exécution unique de l’Effet et une séquence exécution-nettoyage-exécution. Bien sûr, il y a une déconnexion / reconnexion supplémentaire parce que React titille votre code à la recherche de bugs pendant le développement. Mais c’est normal—n’essayez pas d’éliminer ça !

En production, vous ne verriez "✅ Connexion..." qu’une fois. Le remontage des composants ne survient qu’en mode développement, pour vous aider à repérer les Effets qui nécessitent un nettoyage. Vous pouvez désactiver le mode strict pour éviter ce comportement de développement, mais nous vous recommandons de le laisser actif. Ça vous aidera à repérer de nombreux problèmes comme celui ci-avant.

Comment gérer le double déclenchement de l’Effet en développement ?

React remonte volontairement vos composants en développement pour trouver des bugs comme dans l’exemple précédent. La bonne question n’est pas « comment exécuter un Effet une seule fois », mais « comment corriger mon Effet pour qu’il marche au remontage ».

En général, la réponse consiste à implémenter une fonction de nettoyage. La fonction de nettoyage devrait arrêter ou défaire ce que l’Effet avait commencé. La règle générale veut que l’utilisateur ne puisse pas faire la distinction entre un Effet exécuté une seule fois (comme en production) et une séquence mise en place → nettoyage → mise en place (comme en développement).

La plupart des Effets que vous aurez à écrire correspondront à un des scénarios courants ci-après.

Contrôler des widgets non-React

Vous aurez parfois besoin d’ajouter des widgets d’UI qui ne sont pas écrits en React. Par exemple, imaginons que vous souhaitiez ajouter un composant carte à votre page. Il dispose d’une méthode setZoomLevel() et vous aimeriez synchroniser son niveau de zoom avec une variable d’état zoomLevel dans votre code React. L’Effet pour y parvenir ressemblerait à ceci :

useEffect(() => {
const map = mapRef.current;
map.setZoomLevel(zoomLevel);
}, [zoomLevel]);

Remarquez qu’ici on n’a pas besoin de nettoyage. En développement, React appellera cet Effet deux fois, mais ça n’est pas un problème parce qu’appeler setZoomLevel() deux fois avec la même valeur ne fera rien. Ce sera peut-être un poil plus lent, mais ce n’est pas important puisque ce remontage n’aura pas lieu en production.

Certaines API ne vous permettront peut-être pas de les appeler deux fois d’affilée. Par exemple, la méthode showModal de l’élément natif <dialog> lèvera une exception si vous l’appelez deux fois. Implémentez alors une fonction de nettoyage pour refermer la boîte de dialogue :

useEffect(() => {
const dialog = dialogRef.current;
dialog.showModal();
return () => dialog.close();
}, []);

En développement, votre Effet appellera showModal(), puis immédiatement close(), et encore une fois showModal(). Le comportement visible pour l’utilisateur sera exactement le même que si vous aviez juste appelé showModal() une fois, comme en production.

S’abonner à des événements

Si votre Effet s’abonne à quelque chose, sa fonction de nettoyage doit l’en désabonner :

useEffect(() => {
function handleScroll(e) {
console.log(window.scrollX, window.scrollY);
}
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);

En développement, votre Effet appellera addEventListener(), puis immédiatement removeEventListener(), et encore une fois addEventListener() avec le même gestionnaire. Il n’y aura donc qu’un abonnement actif à la fois. Le comportement visible pour l’utilisateur sera exactement le même que si vous aviez juste appelé addEventListener() une fois, comme en production.

Déclencher des animations

Si votre Effet réalise une animation d’entrée, la fonction de nettoyage devrait s’assurer de revenir aux valeurs initiales de l’animation :

useEffect(() => {
const node = ref.current;
node.style.opacity = 1; // Déclencher l’animation
return () => {
node.style.opacity = 0; // Revenir à la valeur initiale
};
}, []);

En développement, l’opacité sera mise à 1, puis à 0, puis encore à 1. Le comportement visible pour l’utilisateur sera exactement le même que si vous aviez juste défini l’opacité à 1 directement, comme ce serait le cas en production. Si vous utilisez une bibliothèque tierce qui prend en charge le tweening, votre fonction de nettoyage devra aussi en réinitialiser la chronologie.

Charger des données

Si votre Effet charge quelque chose (par exemple via la réseau), la fonction de nettoyage devrait soit abandonner le chargement soit ignorer son résultat :

useEffect(() => {
let ignore = false;

async function startFetching() {
const json = await fetchTodos(userId);
if (!ignore) {
setTodos(json);
}
}

startFetching();

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

On ne peut pas « défaire » une requête réseau qui a déjà abouti, mais la fonction de nettoyage devrait s’assurer que le chargement qui n’est plus pertinent ne risque pas d’impacter l’application. Si le userId passe de 'Alice' à 'Bob', le nettoyage s’assure que la réponse pour 'Alice' est ignorée même si elle arrive avant celle pour 'Bob'.

En développement, vous verrez deux chargements dans l’onglet Réseau. Ce n’est pas un problème. Avec l’approche ci-avant, le premier Effet sera immédiatement nettoyé de sorte que sa propre variable locale ignore sera mise à true. Ainsi, même si une requête supplémentaire a lieu, cela n’affectera pas l’état grâce au test if (!ignore).

En production, il n’y aura qu’une requête. Si la seconde requête en développement vous dérange, la meilleure approche consiste à utiliser une solution technique qui dédoublonne les requêtes et met leurs réponses dans un cache indépendant des composants :

function TodoList() {
const todos = useSomeDataLibrary(`/api/user/${userId}/todos`);
// ...

Non seulement ça améliorera l’expérience de développement (DX), mais l’application semblera aussi plus rapide. Par exemple, quand une personne naviguera en arrière, elle n’aura plus à attendre le chargement de données parce que le cache sera exploité. Vous pouvez soit construire un tel cache vous-même, soit utiliser une des nombreuses alternatives au chargement manuel de données.

En détail

Que préférer au chargement de données dans les Effets ?

Écrire nos appels fetch dans les Effets est une façon populaire de charger des données, en particulier pour des applications entièrement côté client. Il s’agit toutefois d’une approche de bas niveau qui comporte plusieurs inconvénients significatifs :

  • Les Effets ne fonctionnent pas côté serveur. Ça implique que le HTML rendu côté serveur avec React proposera un état initial sans données chargées. Le poste client devra télécharger tout le JavaScript et afficher l’appli pour découvrir seulement alors qu’il lui faut aussi charger des données. Ce n’est pas très efficace.
  • Charger depuis les Effets entraîne souvent des « cascades réseau ». On affiche le composant parent, il charge ses données, affiche ses composants enfants, qui commencent seulement alors à charger leurs propres données. Si le réseau n’est pas ultra-rapide, cette séquence est nettement plus lente que le chargement parallèle de toutes les données concernées.
  • Charger depuis les Effets implique généralement l’absence de pré-chargement ou de cache des données. Par exemple, si le composant est démonté puis remonté, il lui faudrait charger à nouveau les données dont il a besoin.
  • L’ergonomie n’est pas top. Écrire ce genre d’appels fetch manuels nécessite pas mal de code générique, surtout lorsqu’on veut éviter des bugs tels que les race conditions.

Cette liste d’inconvénients n’est d’ailleurs pas spécifique à React. Elle s’applique au chargement de données lors du montage quelle que soit la bibliothèque. Comme pour le routage, bien orchestrer son chargement de données est un exercice délicat, c’est pourquoi nous vous recommandons plutôt les approches suivantes :

  • Si vous utilisez un framework, utilisez son mécanisme intégré de chargement de données. Les frameworks React modernes ont intégré le chargement de données de façon efficace afin d’éviter ce type d’ornières.
  • Dans le cas contraire, envisagez l’utilisation ou la construction d’un cache côté client. Les solutions open-source les plus populaires incluent React Query, useSWR, et React Router 6.4+. Vous pouvez aussi construire votre propre solution, auquel cas vous utiliseriez sans doute les Effets sous le capot, mais ajouteriez la logique nécessaire au dédoublonnement de requêtes, à la mise en cache des réponses, et à l’optimisation des cascades réseau (en préchargeant les données ou en consolidant vers le haut les besoins de données des routes).

Vous pouvez continuer à charger les données directement dans les Effets si aucune de ces approches ne vous convient.

Envoyer des données analytiques

Prenez le code ci-après, qui envoie un événement analytique lors d’une visite de la page :

useEffect(() => {
logVisit(url); // Envoie une requête POST
}, [url]);

En développement, logVisit sera appelée deux fois pour chaque URL, ce qui incite à un correctif. Nous vous recommandons de laisser ce code tel quel. Comme pour les exemples précédents, il n’y a pas de différence visible de comportement entre son exécution une ou deux fois. D’un point de vue pratique, logVisit ne devrait même rien faire en développement, parce que vous ne souhaitez pas polluer vos métriques de production avec les machines de développement. Votre composant est remonté chaque fois que vous sauvez son fichier, il notifierait donc des visites en trop en développement de toutes façons.

En production, il n’y aura pas de doublon de visite.

Pour déboguer les événements analytiques que vous envoyez, vous pouvez déployer votre appli sur un environnement de recette (qui s’exécute en mode production), ou temporairement désactiver le mode strict et ses vérifications de montage en mode développement. Vous pourriez aussi envoyer vos événements analytiques au sein de gestionnaires d’événements de changement de route plutôt que depuis les Effets. Pour obtenir des analyses plus granulaires encore, les observateurs d’intersection peuvent vous aider à surveiller quels composants sont dans la zone visible de la page, et mesurer combien de temps ils y restent.

Pas un Effet : initialiser l’application

Certains traitements ne devraient s’exécuter que lorsque l’application démarre. Sortez-les de vos composants :

if (typeof window !== 'undefined') { // Vérifie qu’on est dans un navigateur
checkAuthToken();
loadDataFromLocalStorage();
}

function App() {
// ...
}

Ça garantit que ces traitements ne sont exécutés qu’une fois, après que le navigateur a chargé la page.

Pas un Effet : acheter un produit

Parfois, même une fonction de nettoyage ne suffit pas à masquer les conséquences visibles de la double exécution d’un Effet. Par exemple, peut-être votre Effet envoie-t-il une requête POST qui achète un produit :

useEffect(() => {
// 🔴 Erroné : cet Effet s’exécute 2 fois en développement, on a donc un problème.
fetch('/api/buy', { method: 'POST' });
}, []);

Vous ne voulez sans doute pas acheter le produit deux fois. C’est justement pour ça que ce type de traitement n’a pas sa place dans un Effet. Et si l’utilisateur navigue ailleurs puis revient ? Votre Effet s’exécuterait encore. On ne veut pas déclencher un achat quand l’utilisateur visite la page ; on veut acheter quand l’utilisateur clique sur le bouton Acheter.

Ce n’est pas le rendu qui déclenche l’achat, c’est une interaction spécifique. On ne devrait donc l’exécuter que lorsque l’utilisateur active le bouton. Supprimez l’Effet et déplacez la requête à /api/buy dans le gestionnaire d’événement du bouton Acheter :

function handleClick() {
// ✅ L’achat est un événement, car il est causé par une interaction spécifique.
fetch('/api/buy', { method: 'POST' });
}

Ça illustre bien le fait que si le remontage casse la logique de votre application, il s’agit probablement d’un bug dans votre code. Du point de vue de l’utilisateur, visiter la page ne devrait en rien différer de la visiter, puis cliquer un lien, puis y revenir. React vérifie que vos composants obéissent à ce principe en les remontant une fois lors du développement.

Tous ensemble cette fois

Le bac à sable ci-après devrait vous aider à affiner votre intuition du fonctionnement des Effets en pratique.

Cet exemple utilise setTimeout pour planifier un message en console avec le texte saisi, qui surviendra 3 secondes après l’exécution de l’Effet. La fonction de nettoyage annule le timer mis en place. Commencez par activer « Monter le composant ».

import { useState, useEffect } from 'react';

function Playground() {
  const [text, setText] = useState('a');

  useEffect(() => {
    function onTimeout() {
      console.log('⏰ ' + text);
    }

    console.log('🔵 Planification du message "' + text + '"');
    const timeoutId = setTimeout(onTimeout, 3000);

    return () => {
      console.log('🟡 Annulation du message "' + text + '"');
      clearTimeout(timeoutId);
    };
  }, [text]);

  return (
    <>
      <label>
        Que dire :{' '}
        <input
          value={text}
          onChange={e => setText(e.target.value)}
        />
      </label>
      <h1>{text}</h1>
    </>
  );
}

export default function App() {
  const [show, setShow] = useState(false);
  return (
    <>
      <button onClick={() => setShow(!show)}>
        {show ? 'Démonter' : 'Monter'} le composant
      </button>
      {show && <hr />}
      {show && <Playground />}
    </>
  );
}

Vous verrez d’abord trois lignes : Planification du message "a", Annulation du message "a", et à nouveau Planification du message "a". Trois secondes plus tard, une ligne apparaîtra qui dira a. Comme vous l’avez appris plus tôt, la paire supplémentaire d’annulation / planification vient de ce que React remonte le composant une fois en développement pour vérifier que vous avez implémenté le nettoyage correctement.

À présent, modifiez la saisie pour qu’elle indique abc. Si vous le faites suffisamment vite, vous verrez Planification du message "ab" immédiatement suivi de Annulation du message "ab" et Planification du message "abc". React nettoie toujours l’Effet du rendu précédent avant de déclencher l’Effet du rendu suivant. C’est pourquoi même si vous tapez vite, il y aura au plus un timer actif à la fois. Modifiez la saisie deux ou trois fois en gardant un œil sur la console pour bien sentir la façon dont les Effets sont nettoyés.

Tapez à présent quelque chose dans la saisie et cliquez immédiatement sur « Démonter le composant ». Remarquez que l’Effet du dernier rendu est nettoyé par le démontage. Ici, le dernier timer mis en place est annulé avant même d’avoir pu se déclencher.

Pour finir, modifiez le composant ci-avant en commentant sa fonction de nettoyage, de sorte que les timers ne seront pas annulés. Essayez de taper rapidement abcde dans le champ. Que vous attendez-vous à voir arriver au bout de trois secondes ? Le console.log(text) planifié va-t-il afficher la dernière valeur de text et produire cinq lignes abcde ? Essayez pour tester votre intuition !

Au bout de trois secondes, vous devriez voir une séquence de messages (a, ab, abc, abcd, et abcde) plutôt que cinq messages abcde. Chaque Effet « capture » la valeur text du rendu qui l’a déclenché. Peu importe que l’état text ait changé ensuite : un Effet issu d’un rendu où text = 'ab' verra toujours 'ab'. En d’autres termes, les Effets de chaque rendu sont isolés les uns des autres. Si vous vous demandez comment ça fonctionne, nous vous invitons à vous renseigner sur les fermetures lexicales (closures, NdT).

En détail

Chaque rendu à ses propres Effets

Vous pouvez considérer que useEffect « attache » un morceau de comportement au résultat du rendu. Prenez cet Effet :

export default function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);

return <h1>Bienvenue dans {roomId} !</h1>;
}

Voyons ce qui se passe exactement lorsque l’utilisateur se promène dans l’appli.

Rendu initial

L’utilisateur visite <ChatRoom roomId="general" />. Substituons mentalement la référence à roomId par sa valeur, 'general' :

// JSX pour le premier rendu (roomId = "general")
return <h1>Bienvenue dans general !</h1>;

L’Effet fait aussi partie du résultat du rendu. L’Effet du premier rendu devient :

// Effet du premier rendu (roomId = "general")
() => {
const connection = createConnection('general');
connection.connect();
return () => connection.disconnect();
},
// Dépendances du premier rendu (roomId = "general")
['general']

React exécute l’Effet, qui nous connecte au salon de discussion 'general'.

Rendu suivant avec les mêmes dépendances

Supposons que <ChatRoom roomId="general" /> fasse un nouveau rendu. Le résultat JSX est exactement le même :

// JSX pour le deuxième rendu (roomId = "general")
return <h1>Bienvenue dans general !</h1>;

React voit que rien n’a changé dans le résultat, et ne touche donc pas au DOM.

L’Effet du deuxième rendu ressemble à ceci :

// Effet du deuxième rendu (roomId = "general")
() => {
const connection = createConnection('general');
connection.connect();
return () => connection.disconnect();
},
// Dépendances du deuxième rendu (roomId = "general")
['general']

React compare le ['general'] du deuxième rendu au ['general'] du premier. Puisque les dépendances sont identiques, React ignore l’Effet du deuxième rendu. Il n’est jamais appelé.

Rendu suivant avec des dépendances différentes

L’utilisateur visite alors <ChatRoom roomId="travel" />. Cette fois, le JSX renvoyé est différent :

// JSX pour le troisième rendu (roomId = "travel")
return <h1>Bienvenue dans travel !</h1>;

React met à jour le DOM pour remplacer "Bienvenue dans general" par "Bienvenue dans travel".

L’Effet du troisième rendu ressemble à ceci :

// Effet du troisième rendu (roomId = "travel")
() => {
const connection = createConnection('travel');
connection.connect();
return () => connection.disconnect();
},
// Dépendances du troisième rendu (roomId = "travel")
['travel']

React compare le ['travel'] du troisième rendu au ['general'] du deuxième. Une dépendance est différente: Object.is('travel', 'general') vaut false. L’Effet ne peut pas être sauté.

Avant de pouvoir appliquer l’Effet du troisième rendu, React doit nettoyer le dernier Effet qui a été exécuté. Celui du deuxième rendu a été sauté, donc React doit nettoyer l’Effet du premier rendu. Si vous remontez pour voir le premier rendu, vous verrez que sa fonction de nettoyage appelle disconnect() sur la connexion créée avec createConnection('general'). Ça déconnecte l’appli du salon de discussion 'general'.

Après ça, React exécute l’Effet du troisième rendu. Il nous connecte au salon de discussion 'travel'.

Démontage

Au bout du compte, notre utilisateur s’en va, et le composant ChatRoom est démonté. React exécute la fonction de nettoyage du dernier Effet exécuté : celui du troisième rendu. Sa fonction de nettoyage referme la connexion createConnection('travel'). L’appli se déconnecte donc du salon 'travel'.

Comportements spécifiques au développement

Quand le mode strict est actif, React remonte chaque composant une fois après leur montage initial (leur état et le DOM sont préservés). Ça vous aide à repérer les Effets qui ont besoin d’être nettoyés et permet la détection en amont de problèmes tels que les race conditions. React effectue aussi ce remontage lorsque vous sauvegardez vos fichiers en développement. Dans les deux cas, ces comportements sont limités au développement.

En résumé

  • Contrairement aux événements, les Effets sont déclenchés par le rendu lui-même plutôt que par une interaction spécifique.
  • Les Effets vous permettent de synchroniser un composant avec un système extérieur (API tierce, appel réseau, etc.)
  • Par défaut, les Effets sont exécutés après chaque rendu (y compris le premier).
  • React sautera un Effet si toutes ses dépendances ont des valeurs identiques à celles du rendu précédent.
  • Vous ne pouvez pas « choisir » vos dépendances. Elles sont déterminées par le code au sein de l’Effet.
  • Un tableau de dépendances vide ([]) correspond à une exécution seulement lors du « montage » du composant, c’est-à-dire son apparition à l’écran.
  • En mode strict, React monte les composants deux fois (seulement en développement !) pour éprouver la qualité d’implémentation des Effets.
  • Si votre Effet casse en raison du remontage, vous devez implémenter sa fonction de nettoyage.
  • React appellera votre fonction de nettoyage avant l’exécution suivante de l’Effet, ainsi qu’au démontage.

Défi 1 sur 4 ·
Focus au montage

Dans l’exemple ci-après, la formulaire exploite un composant <MyInput />.

Utilisez la méthode focus() du champ pour faire en sorte que MyInput obtienne automatiquement le focus lorsqu’il apparaît à l’écran. Une implémentation commentée existe déjà, mais elle ne marche pas tout à fait. Comprenez pourquoi, et corrigez-la. (Si vous connaissez l’attribut autoFocus, faites comme s’il n’existait pas : nous en réimplémentons la fonctionnalité à partir de zéro.)

import { useEffect, useRef } from 'react';

export default function MyInput({ value, onChange }) {
  const ref = useRef(null);

  // TODO: Ça ne marche pas tout à fait, corrigez ça.
  // ref.current.focus()

  return (
    <input
      ref={ref}
      value={value}
      onChange={onChange}
    />
  );
}

Pour vérifier que votre solution fonctionne, cliquez « Afficher le formulaire » et vérifiez que le champ de saisie reçoit le focus (il a un halo et le curseur y est actif). Cliquez sur « Masquer le formulaire » puis à nouveau « Afficher le formulaire ». Vérifiez que le champ a de nouveau le focus.

MyInput ne devrait recevoir le focus qu’au montage plutôt qu’après chaque rendu. Pour le vérifier, cliquez sur « Afficher le formulaire » puis jouez avec la case à cocher « Le mettre en majuscules ». Ça ne devrait pas redonner le focus au champ textuel à chaque bascule.