Believemy logo purple

Comment bien utiliser le hook useEffect et useRef de React ?

Découvrez les meilleures pratiques à adopter avec useEffect et useRef, à quoi ça sert et comment les utiliser de la bonne façon.
Mis à jour le 3 décembre 2024
Believemy logo

Les useEffect, c'est quoi ? Et à quoi ça sert ?

Un useEffect en React est une fonction que l'on appelle un hook. Les hooks  commencent par le préfixe "use" (c'est une convention de nommage).  Le useEffect permet de gérer l'état du cycle de vie d'un composant et en parallèle d'exécuter des actions (effet secondaire) de manière réactive.

Le useEffect s'exécute de manière asynchrone. Il permet de contrôler le comportement d'un composant lors du montage (mount)  et du démontage (unmount) de celui-ci. 

 

Comprendre le cycle de vie

Passons par une simple analogie. Le soleil. Il se lève, il évolue puis il se couche. C'est le cycle de vie du soleil. Puis il recommence le jour suivant et ainsi de suite. 

Analogie Cycle de vie du soleil et d'un composant React

Pour un composant React c'est exactement la même chose.

Tout comme le soleil, le cycle de vie d'un composant est composé de trois phases :

  • Phase 1 : Le mounting (montage)
  • Phase 2 : L'updating (mise à jour)
  • Phase 3 : L'unmounting (démontage)

Pour chacune de ces phases nous avons deux étapes bien distinctes. Le Render (rendu). Sauf pour la phase de démontage, qui n'a pas de rendu. Puis, l'étape 2, qui fonction de sa phase, aura un nom différent. 

Pour la phase 1 elle sera nommée ComponentDidMount (il a FINI de se lever). 
Pour la phase 2 elle sera nommée ComponentDidUpdate (il a FINI d'évoluer). 
Et enfin pour la phase 3 cette étape 2 sera nommée ComponentWillUnmount  (il va ALLER  se coucher)

Cycle de vie et phases d'un composant React

Le useEffect  va donc servir à exécuter du code qui interviendra uniquement dans l'une de ces trois phases. Soit au montage, soit à la mise à jour, soit au démontage.

 

Et à quoi ça ressemble ?

Le useEffect accepte deux arguments. Le premier argument est une fonction callback qui est obligatoire,  le deuxième, facultatif est nommé, le tableau de dépendances.

JSX
useEffect(() => {
  // Ici le code, l'"effet" qui sera exécuté ==> MOUNT

  return () => {
    // l'effet de clean up (nettoyage) ==> UNMOUNT
  }
}, 
  [] // le tableau de dépendances. 
     // Permet de provoquer la mise à jour de cet effet si besoin
) 

 

Comprendre le paramètre de tableau de dépendances

Ce paramètre, va influer sur le moment,  quand est exécuté le useEffect.

1. Le paramètre n'est pas présent.
Le useEffect sera exécuté à chaque rendu de l'application.     

JSX
useEffect(() => {
  console.log("Je suis exécuté à chaque rendu")
}) 

        

2. Le paramètre est un tableau vide

Le useEffect sera exécuté une seule fois au premier rendu de la page (au montage du composant).  Sa fonction cleanup au démontage.

JSX
useEffect(() => {
  console.log("Je suis exécuté qu'une seule fois (Mount)") 
  
  return () => {
    console.log("Je suis exécuté seulement au déchargement (Unmount)")
  }
}, []) 

 

3. Le tableau des dépendances contient des références vers les valeurs et - ou fonctions qui seront utilisées dans le code du useEffect.

Dans le cas d'une variable (useState) le useEffect sera exécuté à chaque fois qu'une valeur présente dans ce tableau de dépendances est modifiée. Le useEffect est exécuté uniquement après le ré-rendu du composant dans le DOM (si c'est une valeur d'un useState).  

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

useEffect(() => {
  console.log(`Je suis exécuté à chaque fois que la valeur de Count change = ${count}`)
        
  return () => {
    console.log("Je suis exécuté avant chaque ré-exécution, je nettoie l'execution précedente = ${count}")
  } 

}, [count])

 

Quel est le but des useEffect ?

Le but premier des useEffect, est la synchronisation des données avec un ou plusieurs éléments. C'est une sorte d'"event listener" qui exécute une action à un moment précis.

Par exemple nous désirons que le titre de notre page soit mise à jour en fonction d'une valeur totalPrice.

JSX
const [totalPrice, setTotalPrice] = useState(0);

useEffect(() => {
  document.title = `Total de vos achats ${totalPrice} €`
}, [totalPrice]) 

Ici on veux que notre "title" soit synchronisé avec la valeur de la variable "totalPrice". En passant par le useEffect on évite ainsi tout effet de bord non désiré.

Voici un deuxième exemple simple, où on traque le scroll de la page :

JSX
useEffect(() => { 
  const onScroll = () => { 
    console.log('SCROLL'); 
  }; 

  window.addEventListener('scroll', onScroll); 
  
  return () => { 
    window.removeEventListener('scroll', onScroll); 
  }; 
}, []);

Comme nous le voyons, il ne faut pas oublier d'annuler l' "eventListener" dans la cleanup function.

 

Ce qu'il ne faut pas faire

Une mauvaise pratique avec le useEffect est de modifier un state en fonction d'un autre et dont le changement de valeur est traqué par un autre useEffect. L'effet de bord premier, est que cela va générer des rendus inutiles. Et surtout ce n'est pas de la synchronisation en soit.

Mauvais exemple :

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

const Test = () => {
  const [valueA, setValueA] = useState(false)
  const [valueB, setValueB] = useState(false)
  const [valueC, setValueC] = useState(false)

  const handleValueAClick = () => {
    setValueA(true)
  }

  const handleToggleValueB = () => {
    setValueB(currentValue => !currentValue)
  }

  useEffect(() => {
    if (valueA && valueB && valueC) {
      setValueC(false)
      return
    }
    setValueB(true)
  }, [valueA, valueB, valueC])
}

Ici comme on peut le voir, en fonction des states, on modifie d'autres states. Le useEffect n'est pas fait pour modifier des valeurs (state) en fonction de conditions.

À la place il faut utiliser cette logique dans des fonctions séparées pour modifier nos states.

JSX
const updateValuesIfNeeded = () => {
 if (valueA && valueB && valueC) {
    setValueC(false)
    return
  }
  setValueB(true)
}

const handleValueAClick = () => {
  setValueA(true)
  updateValuesIfNeeded()
}

const handleToggleValueB = () => {
  setValueB(currentValue => !currentValue)
  updateValuesIfNeeded()
}

En faisant ceci, nous nous assurons de ne pas relancer de ré-rendus supplémentaires pour rien et on se prémunira de l'appartion de futurs bugs. 

Si vous devez gérer des scénarios complexes vous pouvez utiliser des bibliothèques tierses de gestion d'états comme XState pour vous aider.

Allons encore un peu plus loin dans l'utilisation du useEffect avec deux cas de figures. L'utilisation de setInterval et de fetch.

 

Comment utiliser setInterval avec useEffect ?

Nous allons, créer un compteur de base, je vais vous montrer comment éviter un bug connu.

Mise en place :

JSX
import { useState } from 'react';

export const Counter = () => {
  const [count, setCount] = useState(0)
  
  return (
    <div>
      Valeur du compteur : {count}
    </div>
  )
}

Naïvement, dans notre composant, nous pourrions simplement ajouter le setInterval et cela fonctionnera.

JSX
import { useState } from 'react';

export const Counter = () => {
  const [count, setCount] = useState(0)

  setInterval(() => {
    setCount(count + 1)
  }, 1000)
  
  return (
    <div>
      Valeur du compteur : {count}
    </div>
  )
}

Cependant un bug visuel apparaitra très rapidement. 

Le problème ici est que à chaque modification du state notre composant "Counter" sera relancé et un nouvel setInterval sera mis en place. Nous aurons donc une multitude de setInterval qui tenteront de mettre à jour le state en même temps. Alors que par principe nous avons besoin d'avoir UN SEUL setInterval.

Pour résoudre ce premier problème, implémentons le useEffect. Nous devrons également faire attention à bien ajouter un clearInterval dans la cleanup function de notre useEffect.

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

export const Counter = () => {
  const [count, setCount] = useState(0)

  useEffect(() => {
    const intervalID = setInterval(() => {
      setCount(count + 1)
    }, 1000)

    return () => {
      cleanInterval(intervalID)
    }
  }, [])
  
  return (
    <div>
      Valeur du compteur : {count}
    </div>
  )
}

Aïe ! Ca ne fonctionne pas. Pourquoi ? 

Le soucis est un problème de closure. En effet la fonction callback passée à setInterval  a son propre environnement lexical ce qui signifie que la variable count a été mise en mémoire dans cet environnement et c'est cette "sauvegarde" qui est constamment utilisée. 

Cela ne nous permet donc pas d'incrémenter la valeur de notre state. Il nous faut trouver un moyen d'avoir accès à la valeur courante count de notre state.

Au lieu de passer l'expression dans setCount pour modifier notre state, comme on le fait d'habitude. Qui fonctionne dans plus de 95% des cas. Nous allons lui passer une fonction callback. Cette fonction prend en paramètre la valeur en cours de notre state. Ainsi,  nous ne sommes plus directement dépendant de la variable count de notre state.

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

export const Counter = () => {
  const [count, setCount] = useState(0)

  useEffect(() => {
    const intervalID = setInterval(() => {
      setCount( ( currentStateValue ) => {
        return currentStateValue + 1
      })
    }, 1000)

    return () => {
      cleanInterval(intervalID)
    }
  }, [])
  
  
  return (
    <div>
      Valeur du compteur : {count}
    </div>
  )
}

Ce callback peut-être utile dans deux cas. Ici, comme nous venons de le voir avec setInterval ou similaire. Et dans le cas ou nous souhaiterions modifier le state plusieurs fois de suite en une seule execution.

 

Comment utiliser Fetch avec useEffect ?

Vous avez sûrement l'habitude de faire quelque chose comme ceci pour charger des données depuis une API.

JSX
useEffect(() => {
  fetch(url)
  .then(response => response.json())
  .then(data => setState(data))  
}, [])

Le problème c'est que cela peut générer une erreur inattendue. Une des solutions que l'on voit souvent c'est d'utiliser un flag comme garde fou.

JSX
useEffect(() => {
  let isCancelled = false
  fetch(url)
  .then((response) => response.json())
  .then((data) => {
     if (!isCancelled) {
       setState(data))  
     }     
   });

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

En plus de ne pas être très élégante, le problème avec cette solution, c'est que l'on ne synchronise rien, même si tout fonctionne.

En effet, si nous lançons plusieurs fois ce useEffect, le fetch va toujours s'exécuter. Notre garde fou "isCancelled" nous permet juste de récupérer correctement les données du dernier Fetch  sans avoir une erreur non voulue. 

La meilleure méthode pour le moment, pour contrer ce problème, c'est d'utiliser un "signal" avec Fetch.

JSX
useEffect(() => {
  const abortController = new AbortController();
  const signal = abortController.signal; 
  
  fetch(url, { signal })
  .then((response) => response.json())
  .then((data) => setState(data))            
  .catch ((err) => {
     if (err.name !== "AbortError") {
       // gestion des erreurs
     }
   });

   return () => {
     abortController.abort()
   }
}, [])

De cette manière, si la requête est interrompue, nous nous assurons que l'instance de Fetch est bien détruite. Pour le "catch" nous interceptons toutes les autres erreurs sauf  les erreurs de type "AbortError" qui proviennent du AbortController (notre signal) qui nous indique que la requête a été annulée.

Pour éviter d'utiliser un useEffect pour fetch vos données, vous pouvez faire usage d'une bibliothèque tierse, comme l'excellente TanStack Query ou SWR

Pour l'heure actuelle, il n'y a pas encore de solution native pour "fetcher" des données en dehors d'un useEffect.

 

Le useRef, c'est quoi ? Et à quoi ça sert ?

Le useRef tout comme le useEffect, commence par le préfixe "use". C'est donc, un hook. Nous pouvons faire de nombreuses choses avec. Il permet de garder en référence une valeur, un composant, etc. en mémoire qui n'est pas nécessaire pour le rendu (exemple : on souhaite comptabiliser le nombre de click sur un bouton, on contrôler directement un évènement du DOM : mouseEnter, mouseLeave ou encore un "Observer" ... ).

Cela va nous permettre identifier et interagir facilement avec ces éléments spécifiques. On utilise généralement useRef quand le changement d'une valeur ne nécessite pas de re-rendu.

 

Comment se servir de useRef ?

Il suffit de déclarer le useRef. Celui-ci contient une unique propriété current qui contient la valeur stockée. Il prend en paramètre la valeur initiale. Pour modifier la valeur il suffit juste de faire : myRef.current = newValue

useRef peut donc être une très bonne alternative au useState suivant les cas.

JSX
const Component = () => {
  const ref = useRef(12);

  const handleClick = () => {
    ref.current += 1
    console.log('Nombre de click : ', ref.current)
  }
  /* ... */

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

 

Utiliser une Ref pour contrôler composant

Un exemple simple. Lorsque l'on appuie sur le bouton nous voulons donner le focus à l'input en-dessous.

Exemple 1 :
Utilisation normale de ref pour donner le focus à un input.

JSX
import "./styles.css";
import { useRef } from "react";

const App = () => {  
  // state
  const inputRef = useRef();

  // comportements
  const handleClick = () => {
    console.log("inputRef.current :", inputRef.current);
    inputRef.current.focus();
  };

  // Rendu
  return (  
    <div className="App">  
      <button onClick={handleClick1}>Focus sur input</button>
      <div className="input-field">
        <label htmlFor="input-ref">input</label>
        <input
          ref={inputRef}
          type="text"
          name="input-ref"
          placeholder="input ref"
         />
      </div>
    </div>  
  )  
}  
  
export default App

 

Exemple 2 :
Utilisation de ref pour donner le focus à un input situé à l'intérieur d'un composant personnalisé.

components/InputText.jsx

JSX
import React from "react";

const InputText = React.forwardRef(({ name, placeholder }, ref) => {
  return (
    <>
      <label htmlFor={name}>TextInput</label>
      <input
        ref={ref}
        type="text"
        name={name}
        placeholder={placeholder}      
      />
    </>
  );
});

export default InputText;

App.js

JSX
import "./styles.css";
import { useRef } from "react";
import InputText from "./components/InputText";

const App = () => {  
  // state
  const inputComponentRef = useRef();

  // comportements
  const handleClick = () => {
    console.log("inputComponentRef.current :", inputComponentRef.current);
    inputComponentRef.current.focus();
  };

  // Rendu
  return (  
    <div className="App">  
      <button onClick={handleClick1}>Focus sur input component</button>
      <div className="input-component-field">
        <label htmlFor="input-component-ref">input component</label>
        <input
          ref={inputComponentRef}
          type="text"
          name="input-component-ref"
          placeholder="input component ref"
         />
      </div>
    </div>  
  )  
}  
  
export default App

 

Quand utiliser useRef ?

Comme dit précédemment, useRef est parfait pour stocker des valeurs qui ne sont pas affichées. On peux utiliser useRef, par exemple dans une barre de recherche. En effet la valeur de notre input est utilisée nulle part.

JSX
const TestRef = () => {
  const inputSearchRef = useRef("")

  const handleInputSearchChange = () => {
    alert('Ref value = ' + inputSearchRef.current.value)
  }

  return (
    <>
      <input ref={inputSearchRef} defaultValue="" onChange={handleInputSearchChange}/>
    </>
  )
}

Dans cette exemple, il serait bienvenue d'utiliser un hook personnalisé de type "debounce".

Note : Une fonction debounce est un mécanisme qui permet de retarder l'exécution d'une fonction et qu'elle ne soit pas appelée pendant un laps de temps spécifié. Cela permet d'éviter des appels excessifs à une fonction lors d'événements fréquents, comme la saisie de texte dans un champ. Cette technique permet d'optimiser les performances et de réduire les traitements inutiles.

On peut utiliser un useRef pour garder une référence à un élément HTML comme canvas ou comme dans l'exemple suivant l'élément audio. Nous pouvons ainsi interagir directement avec le DOM sans avoir des rendus inutiles en passant par le React DOM.

Voyons comment tout ça fonctionne au travers de deux exemples concrets.

 

Comment prendre le contrôle d'un lecteur audio avec useRef ?

L'objectif, va être de créer et contrôler un "audio player" personnalisé très simpliste.

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

export const AudioPlayer = () => {
  const [isPlaying, setIsPlaying] = useState(false)
  const audioRef = useRef(null)

  const handlePlayingStateClick = () => {
    if (isPlaying) {
      audioRef.current.pause();      
    } else {
      audioRef.current.play();      
    }
    setIsPlaying(!isPlaying)      
  }
  
  return (
    <>
      <audio ref={audioRef} src="myMusic.mp3" className="hidden" controls />
      { isPlaying ? (
        <button onClick={handlePlayingStateClick}>Pause</button>
      ) : (
        <button onClick={handlePlayingStateClick}>Play</button>
      )}
    </>
  )
}

 

Comment faire un "Infinite scroll"  avec useEffect et useRef ?

Cette exemple va reprendre ce que nous avons vu sur le useEffect et le useRef en vous montrant comment les deux interagissent ensemble.

Mettons en place notre composant et le fetch des données.

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

const cards = () => {
  const [items, setItems] = useState([])
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState(null)
  const [page, setPage] = useState(1)

  const fetchData = async () => {
    const abortController = new AbortController();
    const signal = abortController.signal; 
    
    setIsLoading(true);
    setError(null);

    try {
      const response = await fetch(`https://myapi.com/items?page=${page}`, { signal });
      
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      const data = await response.json();
      
      setItems(items => [...items, ...data]);
      setPage(page => page + 1);
      
    } catch (error) {
      if (error.name !== "AbortError") {
        setError(error); 
      }   
    } finally {
      setIsLoading(false);
    }
  }
  
  /* ... */

}

Ajoutons un useEffect pour charger les données de la première page à l'initialisation de notre composant cards.

JSX
const cards = () => {
  /* ... */
  
  useEffect(() => {
    fetchData();
  }, []);

  /* ... */
}

Maintenant, mettons en place le useRef sur l'élément HTML qui servira à déclencher notre "infinite scroll". Puis insérons un deuxième useEffect qui se chargera d'initaliser l'Intersection Observer

JSX
const cards = () => {
  const scrollObserverTarget = useRef(null);
  
  /* ... */

  useEffect(() => {
    const observer = new IntersectionObserver(
      entries => {
        if (entries[0].isIntersecting) {
          fetchData();
        }
      },
      { threshold: 1 }
    );

    if (scrollObserverTarget.current) {
      observer.observe(scrollObserverTarget.current);
    }

    return () => {
      if (scrollObserverTarget.current) {
        observer.unobserve(scrollObserverTarget.current);
      }
    }
  }, [scrollObserverTarget]);
}

Et pour finir le rendu de notre composant :

JSX
const cards = () => {
  /* ... */
  
  return (
    <div>
      <ul>
        {items.map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
      {isLoading && <p>Chargement de la page <span>{page + 1}</span>...</p>}
      {error && <p>Une erreur est survenue : {error.message}</p>}
      <div ref={scrollObserverTarget}></div>
    </div>
  );
}

 

BONUS : useEffect vs useLayoutEffect

Vous ne le savez peut-être pas mais il existe un hook similaire à useEffect. Le useLayoutEffect. Il possède la même signature, il s'utilise de la même façon mais il existe quelques différences essentielles.

 

Quelles sont les différences ?

Le useEffect est déclenché de manière asynchrone pour ne pas bloquer le processus de rendu du navigateur et il s'exécute après que la navigateur ait fini de repeindre l'écran. Cela signifie qu'il peut y avoir un délai et un autre rafraichissement du rendu avant que les modifications ne soient visibles pour l'utilisateur. 

Le useLayoutEffect quant à lui,  lance son callback de manière synchrone et il s'exécute après le rendu du composant, mais avant que le navigateur n'ait peint l'écran. Il a été conçu pour gérer les effets secondaires qui nécessitent des mises à jour immédiates du DOM.

Note : useLayoutEffect est déclenché avant useEffect.

useLayoutEffect sera principalement utilisé pour faire des manipulations au niveau du DOM. Il sera utile si nous devons effectuer des "mesures" provenant du DOM. Ou, par exemple, pour obtenir la position de défilement,  pour modifier le style, pour l'animation ou transition d'éléments. 

Grâce à useLayoutEffect, nous pouvons donc être sûrs que nos modifications seront définis avant qu'elles ne soient affichées à l'écran.  

Cela signifie, que, quand une mutation du DOM doit être visible pour l'utilisateur, celle-ci doit être déclenchée de manière synchrone avant le rendu final. Cela empêche ainsi, l'utilisateur de voir une incohérence visuelle (flickering).

useLayoutEffect est donc un bon choix pour les tâches de manipulation DOM qui doivent se produire rapidement et sans délai.  

Un autre cas spécial d'utilisation, c'est, si nous mettons une valeur à jour (provenant d'un useRef par exemple) et que nous voulons nous assurer que cette valeur soit à jour avant l'exécution de tout autre code.

ATTENTION !
Il est important de noter que useLayoutEffect peut potentiellement empêcher le navigateur de peindre et nuire aux performances. Il faut donc être extrêmement judicieux pour choisir quand l'utiliser. Il est donc préférable d'éviter de l'utiliser lorsque cela est possible. Dans 99% il faudra préférer useEffect.

Pour les utilisateurs de Next.js :
Si vous rencontrez des erreurs de rendu côté serveur avec useLayoutEffect, il peut être nécessaire de le remplacer par useEffect.

 

Comment afficher une animation avec useLayoutEffect et useRef ?

Voici un exemple, très minimaliste utilisant useRef et  useLayoutEffect pour réaliser des transitions et animations.

Voici d'abord le CSS  :

CSS
*, *::before, *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.App {
  display: grid;
  place-items: center;
  width: 100dvw;
  min-height: 100dvh;
  background-color: #131416;
}

.colorBox {
  display: grid;
  place-items: center;
  background-color: #f1f1f1;
  border: 2px solid #333333;
  min-width: 60%;
  min-height: 60%;
  border-radius: 15px;
  transition: transform 1.5s ease-in-out, 
              background-color 1s linear;
}

.colorBox label {
  display: flex;
  flex-direction: column;
}

.colorBox > label input[type="color"] {
  margin-top: 8px;
  width: 100%;
}

Ci-dessous, ce à quoi notre composant pourrait ressembler :

JSX
import { useState, useRef, useLayoutEffect } from "react";
import "./styles.css";

export default function App() {
  const [colorValue, setColorValue] = useState("#f1f1f1");

  const zoomInRef = useRef(true)
  const colorBoxRef = useRef(null);

  const handleColorChange = (event) => {
    setColorValue(event.currentTarget.value);
    zoomInRef.current = !zoomInRef.current;
  }

  useLayoutEffect(() => {
    const scaleRatio = zoomInRef.current ? 1.3 : 1;
    colorBoxRef.current.style = `background-color: ${colorValue}; transform: scale(${scaleRatio});`;
  }, [colorValue]);

  return (
    <div className="App">
      <div ref={colorBoxRef} className="colorBox">
        <label>
        Choisissez une couleur
        <input type="color" defaultValue="#f1f1f1" onChange={handleColorChange}/>
        </label>
      </div>
    </div>
  );
}

Testez en ligne

 

En conclusion

Maintenant, useEffect, useLayoutEffect et useRef n'ont plus aucun secret pour vous. Vous pouvez dès à présent exploiter tout leur potentiel et les utiliser sereinement de la meilleure manière dans vos projets.

Si vous voulez en savoir sur React, rejoignez notre formation React dès maintenant.

 

Références

Catégorie : Développement
Believemy logo
Commentaires (0)

Vous devez être connecté pour commenter cet article : se connecter ou s'inscrire.

Essayez gratuitement

Que vous essayiez de scaler votre start-up, de créer votre premier site internet ou de vous reconvertir en tant que développeur, Believemy est votre nouvelle maison. Rejoignez-nous, évoluez et construisons ensemble votre projet.

Believemy is with anyone