useState et useEffect : les utiliser correctement

1. Aperçu

useState et useEffect sont les deux hooks les plus basiques de React, mais c’est aussi là que naissent la plupart des bugs : des rendus infinis, un state qui ne se met pas à jour comme prévu, des effects qui s’exécutent à répétition inutilement. Cet article explore le fonctionnement réel des deux hooks, les bonnes pratiques et les erreurs courantes à éviter. Après cette lecture, vous serez plus confiant pour décider « faut-il useEffect ici ? » — une question que beaucoup de développeurs React se posent encore et se trompent.

2. useState : les bases

useState retourne une paire [value, setter]. Chaque fois que vous appelez le setter, React planifie un rendu du composant avec la nouvelle valeur.

import { useState } from "react";

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

  return (
    <div>
      <p>Nombre de clients : {count}</p>
      <button onClick={() => setCount(count + 1)}>Ajouter un client</button>
    </div>
  );
}
  • useState(0) initialise count = 0 seulement au premier rendu.
  • setCount(count + 1) crée un nouveau rendu avec count augmenté de 1.
  • count dans la closure du rendu actuel est immuable — il ne change pas entre deux rendus.

Remarque : Appeler setCount ne change pas immédiatement la valeur de count dans le même rendu. React regroupe les mises à jour et effectue le rendu ensuite.

3. useState : mettre à jour en fonction du state précédent

Si vous appelez le setter plusieurs fois dans le même gestionnaire d’événement sur la base de count (variable « gelée »), le résultat sera incorrect.

function handleTripleClick() {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
  // Résultat : count n'augmente que de 1, pas de 3
}

Utilisez la mise à jour fonctionnelle pour obtenir la valeur la plus récente :

function handleTripleClick() {
  setCount((prev) => prev + 1);
  setCount((prev) => prev + 1);
  setCount((prev) => prev + 1);
  // Résultat : count augmente correctement de 3
}
  • Quand vous passez une fonction au setter, React garantit que prev est toujours la valeur d’état la plus récente au moment du traitement, et non la valeur capturée dans l’ancienne closure.
  • Règle : si le nouvel état dépend de l’ancien état, utilisez toujours la mise à jour fonctionnelle.

4. useState : initialisation différée

Si la valeur d’initialisation nécessite un calcul coûteux, ne l’appelez pas directement — passez une fonction à useState pour qu’elle ne s’exécute qu’une fois.

function parseInitialOrders(raw) {
  console.log("Analyse des données de commande...");
  return JSON.parse(raw);
}

function OrderList({ rawOrders }) {
  // FAUX : parseInitialOrders s'exécute à chaque rendu
  // const [orders, setOrders] = useState(parseInitialOrders(rawOrders));

  // BON : s'exécute seulement au premier rendu
  const [orders, setOrders] = useState(() => parseInitialOrders(rawOrders));

  return <p>Nombre de commandes : {orders.length}</p>;
}
  • La mauvaise approche appelle parseInitialOrders à chaque rendu, bien que React n’utilise que le résultat du premier.
  • La bonne approche passe une référence de fonction ; React ne l’invoque que lors du montage initial.

5. useEffect : les bases

useEffect exécute un effet secondaire après le rendu du composant (après la mise à jour du DOM).

import { useEffect, useState } from "react";

function PageTitleUpdater({ orderCount }) {
  useEffect(() => {
    document.title = `Vous avez ${orderCount} commandes`;
  });

  return <p>Commandes actuelles : {orderCount}</p>;
}
  • Sans dependency array (la deuxième partie), cet effect s’exécute après chaque rendu — au montage comme à la mise à jour.
  • document.title est un effet secondaire typique : il interagit avec le monde extérieur à React (DOM, API), ce n’est pas du calcul de valeur pour le rendu.

6. useEffect : le tableau de dépendances

Le tableau de dépendances détermine quand l’effect s’exécute. Il y a 3 cas à bien comprendre.

6.1. Sans tableau de dépendances

useEffect(() => {
  console.log("S'exécute après CHAQUE rendu");
});

6.2. Tableau de dépendances vide []

useEffect(() => {
  console.log("S'exécute UNE SEULE FOIS après le montage");
}, []);

6.3. Tableau de dépendances avec valeurs

function CustomerProfile({ customerId }) {
  const [customer, setCustomer] = useState(null);

  useEffect(() => {
    fetchCustomer(customerId).then(setCustomer);
  }, [customerId]);

  return <p>{customer?.name ?? "Chargement..."}</p>;
}
  • L’effect ne s’exécute à nouveau que quand customerId change entre deux rendus (comparaison via Object.is).
  • Si vous oubliez customerId dans le tableau, l’effect utilisera l’ancienne valeur de customerId (stale closure) — va chercher le mauvais client quand la prop change.

Remarque : Le plugin ESLint eslint-plugin-react-hooks avec la règle exhaustive-deps vous avertira si vous oubliez une dépendance. Ne désactivez pas cette règle sauf si vous comprenez bien les conséquences.

7. useEffect : la fonction de nettoyage

Quand un effect crée une subscription, un timer ou un écouteur d’événement, vous devez retourner une fonction de nettoyage pour éviter les fuites.

function OrderStatusListener({ orderId }) {
  const [status, setStatus] = useState("pending");

  useEffect(() => {
    const subscription = subscribeToOrderStatus(orderId, setStatus);

    return () => {
      subscription.unsubscribe();
    };
  }, [orderId]);

  return <p>État : {status}</p>;
}
  • La fonction de nettoyage s’exécute avant que l’effect ne s’exécute à nouveau (quand orderId change) et lors du démontage du composant.
  • Sans nettoyage, chaque fois que orderId change, une nouvelle subscription se crée — finalement setStatus est appelé depuis plusieurs anciennes subscriptions, causant des mises à jour incorrectes ou des fuites mémoire.
Ordre d'exécution quand orderId passe de 1 à 2 :
1. nettoyage de l'effect avec orderId = 1
2. nouvel effect s'exécute avec orderId = 2

8. Les erreurs courantes

8.1. Boucles infinies dues à des dépendances manquantes

function BadExample() {
  const [data, setData] = useState({ count: 0 });

  useEffect(() => {
    // nouvel objet à chaque rendu -> l'effect voit toujours un 'changement' si vous mettez data dans les deps
    setData({ count: data.count + 1 }); // cause un re-rendu -> l'effect s'exécute à nouveau -> infini
  }, [data]);

  return <p>{data.count}</p>;
}

Corrigez avec une mise à jour fonctionnelle et isolez la condition d’arrêt, ou supprimez complètement useEffect si cette logique n’a pas besoin d’effet secondaire réel.

8.2. Stale closure dans setTimeout/setInterval

function Countdown() {
  const [seconds, setSeconds] = useState(10);

  useEffect(() => {
    const id = setInterval(() => {
      setSeconds((prev) => prev - 1); // utilise une mise à jour fonctionnelle, ne pas utiliser seconds directement
    }, 1000);

    return () => clearInterval(id);
  }, []); // deps vide car on ne lit pas seconds directement dans l'effect

  return <p>{seconds}</p>;
}
  • Si vous écrivez setSeconds(seconds - 1) au lieu d’une mise à jour fonctionnelle, la closure « se souviendra » toujours de seconds = 10 de la première exécution de l’effect — le compte à rebours se bloque sur une mauvaise valeur.

8.3. Utiliser useEffect pour calculer une valeur dérivée

// FAUX : redondant, provoque un rendu supplémentaire
function OrderSummaryBad({ items }) {
  const [total, setTotal] = useState(0);

  useEffect(() => {
    setTotal(items.reduce((sum, i) => sum + i.price, 0));
  }, [items]);

  return <p>Total : {total}</p>;
}

// BON : calculer directement pendant le rendu
function OrderSummaryGood({ items }) {
  const total = items.reduce((sum, i) => sum + i.price, 0);
  return <p>Total : {total}</p>;
}

La version « FAUX » coûte un rendu supplémentaire : rendu avec l’ancien total, l’effect s’exécute, setTotal déclenche un deuxième rendu avec la bonne valeur. La version « BON » calcule directement pendant le rendu, pas besoin d’effect, pas besoin d’état secondaire.

9. Quand NE PAS utiliser useEffect

Remarque : useEffect est réservé à la synchronisation avec les systèmes extérieurs à React (DOM, API, subscriptions, timers). Si vous pouvez calculer une valeur directement à partir des props/state actuels, n’utilisez pas d’effect.

Trois signes que vous utilisez mal :

  • Vous utilisez un effect pour calculer une valeur dérivée à partir d’un autre state (utilisez une variable ordinaire ou useMemo).
  • Vous utilisez un effect pour gérer un événement utilisateur (gérez directement dans le gestionnaire onClick, onSubmit).
  • Vous utilisez un effect pour réinitialiser l’état quand une prop change — vous pouvez généralement utiliser la prop key pour que React remonte automatiquement le composant au lieu d’utiliser un effect.
// Au lieu d'utiliser un effect pour réinitialiser le formulaire quand customerId change :
// useEffect(() => { setForm(initialForm); }, [customerId]);

// Utilisez key pour remonter le composant, React réinitialise automatiquement tous les états internes :
<CustomerForm key={customerId} customerId={customerId} />

10. Écrire des tests pour le comportement des hooks

Utilisez Jest avec React Testing Library pour tester le comportement réel plutôt que les détails d’implémentation.

npm install --save-dev @testing-library/react @testing-library/user-event jest
// CustomerCounter.test.jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CustomerCounter } from "./CustomerCounter";

test("augmente count au clic du bouton Ajouter un client", async () => {
  const user = userEvent.setup();
  render(<CustomerCounter />);

  expect(screen.getByText("Nombre de clients : 0")).toBeInTheDocument();

  await user.click(screen.getByRole("button", { name: "Ajouter un client" }));

  expect(screen.getByText("Nombre de clients : 1")).toBeInTheDocument();
});
  • Le test appelle render puis simule un vrai clic via userEvent, comme le comportement d’un utilisateur.
  • L’assertion repose sur le contenu affiché (getByText), pas sur l’état interne — c’est la bonne façon de tester avec React : testez le comportement, pas l’implémentation.
PASS  ./CustomerCounter.test.jsx
  ✓ augmente count au clic du bouton Ajouter un client (45 ms)

11. Conclusion

useState semble simple en apparence mais peut facilement causer des erreurs si vous oubliez que la valeur dans la closure est « gelée » à chaque rendu — utilisez la mise à jour fonctionnelle quand le nouvel état dépend de l’ancien. useEffect est puissant mais ne doit être utilisé que pour synchroniser avec les systèmes externes à React ; si une valeur peut être calculée directement lors du rendu, ne l’enrobez pas dans un effect. Maîtriser le tableau de dépendances et la fonction de nettoyage vous aidera à éviter la plupart des bugs courants associés à ces deux hooks.

reacthooksjavascriptfrontendtypescript