Retour au cours

frontend / react

useEffect : cycle de vie, cleanup et dépendances

Leçon 61 exercice

Explication

Ce que vous allez apprendre

  • Comprendre pourquoi le rendu React doit rester pur et où placer les effets de bord
  • Utiliser le tableau de dépendances pour contrôler quand un effet se relance
  • Écrire une fonction de cleanup pour éviter fuites mémoire et abonnements dupliqués
  • Reconnaître et corriger une closure périmée (stale closure) dans un effet
  • Choisir entre useEffect et useLayoutEffect selon le besoin

Dans quel contexte ?

Le composant src/components/UserProfile.jsx charge les données d'un utilisateur via fetch(/api/users/${userId}) dans un useEffect. En naviguant rapidement entre deux profils, l'équipe QA remonte un bug : parfois l'écran affiche les données de l'utilisateur précédent pendant une fraction de seconde. C'est une race condition classique — la réponse de la première requête arrive après celle de la seconde — que le pattern de cleanup avec un flag annule présenté dans cette leçon résout proprement.

Étape 1 : le rendu doit rester pur

Jusqu'ici, un composant se contentait de calculer et retourner du JSX à partir de ses props et de son state. Le rendu doit rester "pur", sans aucune interaction avec le monde extérieur.

Étape 2 : le besoin de sortir de ce cadre

Une application réelle a forcément besoin, à un moment, de sortir de ce cadre : appeler une API, démarrer un minuteur, s'abonner à un événement du navigateur.

Étape 3 : la solution, useEffect

Ces opérations qui touchent à quelque chose d'extérieur au rendu s'appellent des effets de bord. useEffect est le hook conçu pour les exécuter au bon moment, après que React a mis à jour l'écran.

Étape 4 : à quoi sert le tableau de dépendances

Un effet ne doit pas forcément s'exécuter à chaque rendu : on veut souvent le relancer uniquement quand certaines valeurs précises ont changé. Le tableau de dépendances sert exactement à ça.

Étape 5 : les trois cas possibles

Un tableau vide [] signifie "une seule fois, au montage". Un tableau avec des valeurs signifie "à chaque fois que l'une d'elles change". L'absence de tableau signifie "à chaque rendu", ce qui est rarement voulu.

Tableau de dépendancesQuand l'effet s'exécuteCas d'usage typique
[]Une seule fois, au montageAbonnement, fetch initial
[userId]Au montage + à chaque changement de userIdRecharger des données liées à une prop
absent (pas de 2e argument)À chaque renduRarement voulu, souvent un bug

Prérequis

Cette leçon suppose que useState et le concept de re-rendu (leçon 3) sont acquis : un effet réagit précisément aux changements de state ou de props.

Étape 6 : le rôle du cleanup

Il reste un mécanisme essentiel à connaître : la fonction retournée depuis un effet est sa fonction de nettoyage. React l'exécute avant de relancer l'effet suivant, et au démontage du composant, pour éviter les fuites.

Un dernier piège, la closure périmée

Le piège le plus classique est la "closure périmée" : un effet à dépendances vides qui référence une valeur de state la capture telle qu'elle était au premier rendu, pour toujours. La forme fonction du setter permet souvent de s'en affranchir.

Et ensuite ?

Une fois les effets maîtrisés, la prochaine étape consiste à appliquer ces notions aux formulaires contrôlés par React.

Commandes & code

useEffect

Synchroniser un composant avec un système externe : réseau, timer, DOM natif, souscription.

jsx
import { useState, useEffect } from "react";

function Horloge() {
    const [heure, setHeure] = useState(new Date());

    useEffect(() => {
        const id = setInterval(() => setHeure(new Date()), 1000);

        // fonction de cleanup : exécutée avant le prochain effet ET au démontage
        return () => clearInterval(id);
    }, []); // tableau vide -> exécuté une seule fois, au montage

    return <p>{heure.toLocaleTimeString()}</p>;
}
jsx
// Le tableau de dépendances contrôle QUAND l'effet se relance
function ProfilUtilisateur({ userId }) {
    const [utilisateur, setUtilisateur] = useState(null);

    useEffect(() => {
        let annule = false; // protection contre une réponse obsolète (race condition)

        async function charger() {
            const res = await fetch(`/api/users/${userId}`);
            const data = await res.json();
            if (!annule) setUtilisateur(data);
        }

        charger();

        return () => {
            annule = true; // si userId change avant la fin du fetch précédent, on ignore sa réponse
        };
    }, [userId]); // relancé à CHAQUE changement de userId

    if (!utilisateur) return <p>Chargement...</p>;
    return <p>{utilisateur.nom}</p>;
}
jsx
// Piège classique : dépendance manquante -> closure sur une valeur périmée ("stale closure")
function CompteurBuggue() {
    const [compte, setCompte] = useState(0);

    useEffect(() => {
        const id = setInterval(() => {
            setCompte(compte + 1); // "compte" est figé à 0 pour toujours (closure du 1er rendu)
        }, 1000);
        return () => clearInterval(id);
    }, []); // tableau vide alors que "compte" est utilisé à l'intérieur -> bug

    return <p>{compte}</p>;
}

// CORRECT : forme updater, plus besoin de dépendre de "compte"
function CompteurCorrige() {
    const [compte, setCompte] = useState(0);

    useEffect(() => {
        const id = setInterval(() => {
            setCompte((c) => c + 1); // toujours la dernière valeur réelle
        }, 1000);
        return () => clearInterval(id);
    }, []);

    return <p>{compte}</p>;
}
jsx
// Abonnement à un événement DOM natif : pattern standard avec cleanup
function useTailleFenetre() {
    const [taille, setTaille] = useState({ largeur: window.innerWidth, hauteur: window.innerHeight });

    useEffect(() => {
        function gererResize() {
            setTaille({ largeur: window.innerWidth, hauteur: window.innerHeight });
        }
        window.addEventListener("resize", gererResize);
        return () => window.removeEventListener("resize", gererResize);
        // SANS cleanup : chaque montage/démontage ajoute un listener de plus -> fuite mémoire
    }, []);

    return taille;
}
jsx
// useLayoutEffect : variante synchrone, exécutée AVANT que le navigateur peigne l'écran
import { useLayoutEffect, useRef } from "react";

function Tooltip({ texte }) {
    const ref = useRef(null);

    useLayoutEffect(() => {
        // mesure/ajuste le DOM AVANT le paint -> évite un flash visuel (contrairement à useEffect)
        const { width } = ref.current.getBoundingClientRect();
        if (width > window.innerWidth) {
            ref.current.style.left = "0";
        }
    }, []);

    return <div ref={ref} className="tooltip">{texte}</div>;
}

Résumé

  • Le tableau de dépendances détermine QUAND l'effet se relance : [] = une fois, absent = à chaque rendu.
  • La fonction retournée par l'effet est le cleanup : essentielle pour timers, abonnements, souscriptions.
  • Une closure sur une valeur de state dans un effet à dépendances vides devient vite obsolète (stale closure).
  • useLayoutEffect s'exécute avant le paint navigateur : réservé aux mesures/ajustements DOM synchrones.

Exercices pratiques

1 disponible
1

Mission : éliminer la race condition de UserProfile

Objectif : Diagnostiquer un affichage transitoire de données obsolètes et corriger un effet qui manque une dépendance essentielle.

Contexte

Sur UserProfile.jsx, l'équipe QA remonte qu'en cliquant très vite du profil A vers le profil B, l'écran affiche parfois brièvement les données de A avant que celles de B n'apparaissent enfin. Le code actuel est :

jsx
useEffect(() => {
    async function charger() {
        const res = await fetch(`/api/users/${userId}`);
        const data = await res.json();
        setUtilisateur(data);
    }
    charger();
}, [userId]);

Un second effet du même fichier, à dépendances vides, incrémente un compteur de vues toutes les secondes avec setInterval(() => setVues(vues + 1), 1000) et n'avance jamais au-delà de 1.

Résoudre l’exercice →