B / Blog React

Vous n'avez probablement pas besoin de ce useEffect

Cinq patterns React courants où useEffect est inutile et comment refactorer chacun d'eux en un code plus simple et prévisible.

Axe
Architecture React
Publié
Temps de lecture estimé
15 min de lecture
  • Patterns React
  • useEffect
  • Gestion d'état
  • Refactoring

useEffect est l'une des APIs React les plus mal comprises.

Il devient souvent la solution par défaut dès qu'un composant doit faire quelque chose après le rendu : calculer une valeur, mettre à jour un autre état, répondre à un clic, réinitialiser un formulaire ou garder deux variables d'état synchronisées. Le problème est que beaucoup de ces situations ne nécessitent pas du tout un Effet.

Utiliser useEffect inutilement peut introduire des rendus supplémentaires, une interface temporairement incohérente, un flux de données plus complexe, des bugs de tableau de dépendances, des boucles infinies, un état qui peut devenir obsolète et du code plus difficile à comprendre et à tester.

Lorsque la logique ne concerne que les props React, l'état, le rendu ou les événements utilisateur, on peut généralement s'en passer d'un Effet.

Ce guide couvre cinq situations courantes où useEffect est inutile et montre comment refactorer chacune d'elles.

B-01

1. État dérivé

L'état dérivé est une valeur qui peut être calculée à partir des props ou de l'état existants. Imaginez un panier qui stocke à la fois ses articles et son prix total.

Avant : Calculer l'état dérivé dans un Effet

import { useEffect, useState } from "react";

type CartItem = {
  id: string;
  name: string;
  price: number;
  quantity: number;
};

type CartProps = {
  items: CartItem[];
};

export function Cart({ items }: CartProps) {
  const [total, setTotal] = useState(0);

  useEffect(() => {
    const nextTotal = items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    );

    setTotal(nextTotal);
  }, [items]);

  return <p>Total : ${total.toFixed(2)}</p>;
}

Cela fonctionne, mais le composant fait deux rendus quand items change. React fait d'abord un rendu avec l'ancien total, puis l'Effet s'exécute et met à jour total, puis React refait un rendu avec la bonne valeur. Le composant stocke également une information dupliquée : total n'est pas un état indépendant, il est entièrement déterminé par items.

Après : Calculer la valeur pendant le rendu

type CartItem = {
  id: string;
  name: string;
  price: number;
  quantity: number;
};

type CartProps = {
  items: CartItem[];
};

export function Cart({ items }: CartProps) {
  const total = items.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  );

  return <p>Total : ${total.toFixed(2)}</p>;
}

Maintenant, il n'y a pas d'état supplémentaire, pas d'Effet, pas de second rendu, et aucun risque que items et total deviennent incohérents. Le total est toujours correct car il est calculé à partir des items actuels pendant le rendu.

Et pour les calculs coûteux ?

Si le calcul est vraiment coûteux, mémoïsez le calcul plutôt que de le synchroniser via l'état.

import { useMemo } from "react";

export function Cart({ items }: CartProps) {
  const total = useMemo(() => {
    return items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    );
  }, [items]);

  return <p>Total : ${total.toFixed(2)}</p>;
}

N'utilisez pas useMemo automatiquement. La plupart des transformations de tableaux sont assez rapides pour s'exécuter pendant le rendu. Utilisez la mémoïsation uniquement quand le calcul a un coût de performance mesurable.

Règle de refactoring

Si une valeur peut être calculée à partir des props ou de l'état actuels, calculez-la pendant le rendu. Ne la stockez pas dans l'état.

B-02

2. Transformer les props

Un autre pattern courant consiste à recevoir des données via les props, les transformer et enregistrer le résultat dans l'état. Prenons une liste de produits qui doit afficher uniquement les produits disponibles.

Avant : Copier les props transformées dans l'état

import { useEffect, useState } from "react";

type Product = {
  id: string;
  name: string;
  price: number;
  inStock: boolean;
};

type ProductListProps = {
  products: Product[];
};

export function ProductList({ products }: ProductListProps) {
  const [availableProducts, setAvailableProducts] = useState<Product[]>([]);

  useEffect(() => {
    setAvailableProducts(
      products.filter((product) => product.inStock)
    );
  }, [products]);

  return (
    <ul>
      {availableProducts.map((product) => (
        <li key={product.id}>
          {product.name} — ${product.price}
        </li>
      ))}
    </ul>
  );
}

availableProducts n'est pas un état indépendant. C'est une version transformée de products. Cela crée deux sources de vérité qui doivent être maintenues synchronisées manuellement.

Après : Transformer les props pendant le rendu

export function ProductList({ products }: ProductListProps) {
  const availableProducts = products.filter(
    (product) => product.inStock
  );

  return (
    <ul>
      {availableProducts.map((product) => (
        <li key={product.id}>
          {product.name} — ${product.price}
        </li>
      ))}
    </ul>
  );
}

Le flux de données est maintenant direct : products devient filtre, puis liste rendue. Il n'y a pas d'étape de synchronisation au milieu.

Filtrer et trier ensemble

Le même principe s'applique lorsque plusieurs transformations sont nécessaires.

Avant

const [visibleProducts, setVisibleProducts] = useState<Product[]>([]);

useEffect(() => {
  const result = products
    .filter((product) =>
      product.name.toLowerCase().includes(search.toLowerCase())
    )
    .sort((a, b) => a.price - b.price);

  setVisibleProducts(result);
}, [products, search]);

Après

const visibleProducts = products
  .filter((product) =>
    product.name.toLowerCase().includes(search.toLowerCase())
  )
  .sort((a, b) => a.price - b.price);

Attention avec .sort() : il modifie le tableau sur lequel il est appelé. Comme .filter() retourne un nouveau tableau, trier son résultat est sûr. Pour trier les props directement, copiez d'abord le tableau : const sortedProducts = [...products].sort((a, b) => a.price - b.price).

Pour les transformations coûteuses, mémoïsez le résultat avec useMemo.

Règle de refactoring

Ne copiez pas les props dans l'état juste pour les filtrer, trier, mapper, formater ou grouper. Transformez les props pendant le rendu.

B-03

3. Logique événementielle

Les Effets s'exécutent parce qu'un composant a fait un rendu. Les gestionnaires d'événements s'exécutent parce qu'une interaction utilisateur spécifique a eu lieu. Cette distinction est importante.

Supposons que vous vouliez afficher une notification après qu'un utilisateur a ajouté un produit à son panier.

Avant : Réagir à l'état d'événement via un Effet

import { useEffect, useState } from "react";

export function ProductActions() {
  const [addedProduct, setAddedProduct] = useState<string | null>(null);

  useEffect(() => {
    if (!addedProduct) {
      return;
    }

    console.log(`${addedProduct} a été ajouté au panier`);
  }, [addedProduct]);

  function handleAddToCart(productName: string) {
    setAddedProduct(productName);
  }

  return (
    <button onClick={() => handleAddToCart("Clavier mécanique")}>
      Ajouter au panier
    </button>
  );
}

Le code crée un état uniquement pour déclencher un Effet. Mais l'application sait déjà exactement quand le produit a été ajouté : dans handleAddToCart.

Après : Garder la logique événementielle dans le gestionnaire

export function ProductActions() {
  function handleAddToCart(productName: string) {
    console.log(`${productName} a été ajouté au panier`);
  }

  return (
    <button onClick={() => handleAddToCart("Clavier mécanique")}>
      Ajouter au panier
    </button>
  );
}

La relation entre l'action et sa conséquence est maintenant évidente.

Un exemple plus réaliste

Imaginez soumettre une commande et afficher un toast.

Avant

const [orderSubmitted, setOrderSubmitted] = useState(false);

useEffect(() => {
  if (orderSubmitted) {
    toast.success("Votre commande a été soumise");
  }
}, [orderSubmitted]);

async function handleSubmit() {
  await submitOrder();
  setOrderSubmitted(true);
}

Après

async function handleSubmit() {
  await submitOrder();
  toast.success("Votre commande a été soumise");
}

La notification appartient à l'événement de soumission. Utiliser un Effet ici crée une indirection inutile.

Ne pas déplacer chaque fonction dans un Effet

Une idée reçue courante est que les effets de bord comme les appels API doivent toujours être dans useEffect. Ce n'est pas vrai. Un appel API causé par le rendu peut appartenir à un Effet ou à un framework de chargement de données. Un appel API causé par une action utilisateur appartient au gestionnaire d'événements.

async function handleDeleteAccount() {
  const confirmed = window.confirm(
    "Êtes-vous sûr de vouloir supprimer votre compte ?"
  );

  if (!confirmed) {
    return;
  }

  await deleteAccount();
  navigate("/goodbye");
}

L'action est événementielle, donc elle n'a pas besoin d'un Effet.

Règle de refactoring

Demandez pourquoi la logique doit s'exécuter. Si elle doit s'exécuter parce que l'utilisateur a cliqué, soumis, sélectionné, glissé ou tapé quelque chose, mettez-la dans le gestionnaire d'événements correspondant. Ne représentez pas un événement comme un état pour ensuite surveiller cet état avec un Effet.

B-04

4. Réinitialiser l'état

Réinitialiser l'état quand une prop change est une raison courante pour laquelle les développeurs ajoutent des Effets. Prenons un formulaire de commentaire qui doit s'effacer quand l'article sélectionné change.

Avant : Réinitialiser l'état dans un Effet

import { useEffect, useState } from "react";

type CommentFormProps = {
  articleId: string;
};

export function CommentForm({ articleId }: CommentFormProps) {
  const [comment, setComment] = useState("");

  useEffect(() => {
    setComment("");
  }, [articleId]);

  return (
    <textarea
      value={comment}
      onChange={(event) => setComment(event.target.value)}
    />
  );
}

Quand articleId change, React fait d'abord un rendu avec l'ancien état du composant pour le nouvel article. Puis l'Effet s'exécute et efface le commentaire. Cela crée un rendu inutile et associe brièvement l'ancien commentaire au nouvel article.

Après : Réinitialiser le composant avec une clé

type CommentSectionProps = {
  articleId: string;
};

export function CommentSection({ articleId }: CommentSectionProps) {
  return (
    <CommentForm
      key={articleId}
      articleId={articleId}
    />
  );
}

function CommentForm({ articleId }: CommentFormProps) {
  const [comment, setComment] = useState("");

  return (
    <form>
      <label htmlFor={`comment-${articleId}`}>Commentaire</label>

      <textarea
        id={`comment-${articleId}`}
        value={comment}
        onChange={(event) => setComment(event.target.value)}
      />
    </form>
  );
}

Une clé différente indique à React qu'il s'agit d'une instance de composant différente. Quand articleId change, React supprime le formulaire précédent et en crée un nouveau avec un état neuf. C'est généralement la façon la plus propre de réinitialiser tout l'état dans une sous-arborescence.

Réinitialiser seulement une partie de l'état

Parfois, vous ne voulez pas réinitialiser tout le composant. Imaginez une page produit où l'image sélectionnée doit se réinitialiser quand le produit change, mais la préférence de zoom doit rester. Vous pouvez déplacer l'état spécifique au produit dans un enfant avec clé.

type ProductPageProps = {
  product: Product;
};

export function ProductPage({ product }: ProductPageProps) {
  const [zoomEnabled, setZoomEnabled] = useState(false);

  return (
    <>
      <label>
        <input
          type="checkbox"
          checked={zoomEnabled}
          onChange={(event) => setZoomEnabled(event.target.checked)}
        />
        Activer le zoom
      </label>

      <ProductGallery
        key={product.id}
        product={product}
        zoomEnabled={zoomEnabled}
      />
    </>
  );
}

Maintenant, ProductGallery se réinitialise quand le produit change, mais zoomEnabled reste car il appartient au parent.

Une autre option : Stocker des identifiants stables

Parfois, les développeurs stockent un objet sélectionné puis le réinitialisent quand la collection change.

Avant

const [selectedProduct, setSelectedProduct] =
  useState<Product | null>(null);

useEffect(() => {
  setSelectedProduct(null);
}, [products]);

Une meilleure conception peut être de stocker l'ID sélectionné.

const [selectedProductId, setSelectedProductId] =
  useState<string | null>(null);

const selectedProduct =
  products.find((product) => product.id === selectedProductId) ?? null;

Maintenant, si le produit sélectionné disparaît de la collection, selectedProduct devient naturellement null. Il n'y a pas d'Effet de réinitialisation à maintenir.

Règle de refactoring

Quand l'état doit se réinitialiser parce que l'identité de quelque chose a changé, envisagez de donner au composant une clé différente, de déplacer l'état dans un enfant avec clé, ou de stocker un identifiant stable au lieu de dupliquer un objet entier.

B-05

5. Synchroniser l'état React avec l'état React

L'un des plus grands signaux d'alarme dans un composant React est un Effet dont le seul but est de mettre à jour une variable d'état quand une autre variable d'état change.

Avant : Synchroniser deux morceaux d'état

import { useEffect, useState } from "react";

export function RegistrationForm() {
  const [firstName, setFirstName] = useState("");
  const [lastName, setLastName] = useState("");
  const [fullName, setFullName] = useState("");

  useEffect(() => {
    setFullName(`${firstName} ${lastName}`.trim());
  }, [firstName, lastName]);

  return (
    <form>
      <input
        value={firstName}
        onChange={(event) => setFirstName(event.target.value)}
        placeholder="Prénom"
      />

      <input
        value={lastName}
        onChange={(event) => setLastName(event.target.value)}
        placeholder="Nom"
      />

      <p>Bienvenue, {fullName}</p>
    </form>
  );
}

fullName est synchronisé avec firstName et lastName, mais il n'a pas besoin d'être un état.

Après : Calculer la valeur synchronisée

export function RegistrationForm() {
  const [firstName, setFirstName] = useState("");
  const [lastName, setLastName] = useState("");

  const fullName = `${firstName} ${lastName}`.trim();

  return (
    <form>
      <input
        value={firstName}
        onChange={(event) => setFirstName(event.target.value)}
        placeholder="Prénom"
      />

      <input
        value={lastName}
        onChange={(event) => setLastName(event.target.value)}
        placeholder="Nom"
      />

      <p>Bienvenue, {fullName}</p>
    </form>
  );
}

Il y a maintenant une source unique de vérité.

Garder les filtres synchronisés

Supposons qu'un tableau de bord ait un pays et une ville sélectionnés. Quand le pays change, la ville doit être effacée.

Avant : Surveiller une variable d'état pour mettre à jour l'autre

const [country, setCountry] = useState("");
const [city, setCity] = useState("");

useEffect(() => {
  setCity("");
}, [country]);

C'est mieux géré là où le pays change.

Après : Mettre à jour l'état lié dans le même événement

const [country, setCountry] = useState("");
const [city, setCity] = useState("");

function handleCountryChange(nextCountry: string) {
  setCountry(nextCountry);
  setCity("");
}

Les deux mises à jour appartiennent à la même interaction utilisateur, donc les garder ensemble rend le comportement explicite.

Envisager de combiner l'état lié

Quand plusieurs variables d'état changent toujours ensemble, un réducteur ou un objet d'état unique peut mieux exprimer la relation.

type LocationState = {
  country: string;
  city: string;
};

const [location, setLocation] = useState<LocationState>({
  country: "",
  city: "",
});

function handleCountryChange(country: string) {
  setLocation({
    country,
    city: "",
  });
}

function handleCityChange(city: string) {
  setLocation((current) => ({
    ...current,
    city,
  }));
}

Pour des transitions plus complexes, utilisez un réducteur.

type LocationState = {
  country: string;
  city: string;
};

type LocationAction =
  | { type: "countryChanged"; country: string }
  | { type: "cityChanged"; city: string };

function locationReducer(
  state: LocationState,
  action: LocationAction
): LocationState {
  switch (action.type) {
    case "countryChanged":
      return {
        country: action.country,
        city: "",
      };

    case "cityChanged":
      return {
        ...state,
        city: action.city,
      };

    default:
      return state;
  }
}

Cela rend les transitions d'état valides explicites au lieu de compter sur les Effets pour réparer l'état après le rendu.

Règle de refactoring

N'utilisez pas les Effets pour faire rattraper l'état React par un autre état React. À la place, dérivez la valeur pendant le rendu, mettez à jour l'état lié dans le même événement, combinez l'état qui change toujours ensemble, ou utilisez un réducteur pour les transitions complexes.

B-06

Pourquoi les Effets inutiles causent des problèmes

Les Effets inutiles ne sont pas simplement une préférence de style. Ils peuvent créer de vrais bugs.

1. Ils créent des cycles de rendu supplémentaires

Un Effet ne peut pas mettre à jour l'état avant que React ait fait le rendu. La séquence devient : rendu avec valeur obsolète, puis Effet exécuté, puis état mis à jour, puis rendu à nouveau. Quand la valeur aurait pu être calculée pendant le rendu, le premier rendu était inutile.

2. Ils créent des états temporairement incohérents

Supposons qu'un produit change, mais que l'image sélectionnée soit réinitialisée dans un Effet. Pendant un rendu, le composant peut combiner le nouveau produit avec l'ancienne image sélectionnée. Même si l'utilisateur ne remarque jamais l'état intermédiaire, votre composant doit quand même le gérer correctement.

3. Ils dupliquent les sources de vérité

Si fullName est un état et est également déterminé par firstName et lastName, lequel fait autorité ? Chaque valeur dupliquée introduit une responsabilité de synchronisation.

4. Ils masquent la causalité

Ce code est direct : handleSave appelle saveDocument puis showSuccessMessage. Ce code rend la relation plus difficile à suivre : handleSave définit shouldSave à true, et un Effet séparé surveille shouldSave pour appeler saveDocument et showSuccessMessage. La première version vous dit ce qui se passe quand l'utilisateur sauvegarde. La seconde nécessite de tracer les changements d'état à travers différentes parties du composant.

5. Ils rendent la gestion des dépendances plus difficile

Une fois la logique dans un Effet, vous devez gérer chaque dépendance réactive qu'il lit. Manquer une dépendance peut produire des valeurs obsolètes. Ajouter une dépendance peut faire exécuter l'Effet plus souvent que prévu. Essayer de faire taire le linter cache souvent le problème de conception sous-jacent au lieu de le résoudre.

B-07

Quand vous avez vraiment besoin de useEffect

La conclusion n'est pas que useEffect est mauvais. Il est essentiel quand un composant doit se synchroniser avec quelque chose en dehors de React.

APIs navigateur

useEffect(() => {
  document.title = `${unreadCount} messages non lus`;
}, [unreadCount]);

Abonnements aux événements

useEffect(() => {
  function handleOnline() {
    setIsOnline(true);
  }

  function handleOffline() {
    setIsOnline(false);
  }

  window.addEventListener("online", handleOnline);
  window.addEventListener("offline", handleOffline);

  return () => {
    window.removeEventListener("online", handleOnline);
    window.removeEventListener("offline", handleOffline);
  };
}, []);

Widgets tiers

useEffect(() => {
  const map = createMapWidget(containerRef.current);

  return () => {
    map.destroy();
  };
}, []);

Connexions réseau ou temps réel

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

  connection.connect();

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

Ces Effets synchronisent le composant avec des systèmes dont le cycle de vie existe en dehors de React. C'est pour cela que les Effets sont conçus.

Pour les données serveur, envisagez le mécanisme de chargement de données fourni par votre framework ou une bibliothèque dédiée à l'état serveur.

B-08

Une liste de contrôle pratique

Avant d'écrire un Effet, posez ces questions.

Puis-je calculer cette valeur pendant le rendu ?

Si oui, supprimez l'état et calculez la valeur directement : const fullName = `${firstName} ${lastName}`.

Est-ce que je transforme des props ?

Filtrez, mappez, triez, groupez ou formatez-les pendant le rendu : const visibleItems = items.filter(matchesSearch).

Est-ce qu'une action utilisateur spécifique a causé cette logique ?

Mettez la logique dans le gestionnaire d'événements.

function handleSubmit() {
  submitForm();
  showSuccessToast();
}

L'état doit-il se réinitialiser quand une entité change ?

Utilisez une clé, déplacez l'état dans un enfant avec clé, ou stockez un identifiant.

<Editor key={documentId} documentId={documentId} />

Est-ce que je mets à jour une variable d'état parce qu'une autre a changé ?

Dérivez la valeur, mettez à jour les deux dans le même événement, ou reconcevez le modèle d'état.

function handleCountryChange(country: string) {
  setCountry(country);
  setCity("");
}

Est-ce que je me synchronise avec quelque chose en dehors de React ?

C'est là qu'un Effet est probablement approprié. Exemples : APIs navigateur, connexions WebSocket, intégrations DOM, minuteurs, analytics, bibliothèques tierces et abonnements externes.

B-09

Le modèle mental qui change tout

Ne demandez pas comment faire fonctionner cet Effet. Demandez pourquoi cette logique doit s'exécuter.

Il y a trois réponses courantes.

  • Parce que le composant a fait un rendu : calculez la valeur pendant le rendu.
  • Parce que l'utilisateur a fait quelque chose : exécutez la logique dans un gestionnaire d'événements.
  • Parce qu'un système externe doit rester synchronisé : utilisez un Effet.

Cette distinction élimine un nombre surprenant d'Effets.

B-10

En résumé

useEffect ne devrait pas être la colle qui maintient votre état React. Quand l'état React doit être synchronisé avec un autre état React, le modèle de données du composant est souvent le vrai problème.

Préférez calculer plutôt que synchroniser, les gestionnaires d'événements plutôt que les Effets qui surveillent des événements, les clés plutôt que les réinitialisations manuelles, les sources uniques de vérité plutôt que l'état dupliqué, et les réducteurs plutôt que la logique de réparation d'état.

Les Effets sont une échappatoire pour interagir avec des systèmes en dehors de React. Moins vos composants contiennent d'Effets inutiles, plus votre application devient prévisible.

Avant d'ajouter votre prochain useEffect, arrêtez-vous et demandez : est-ce que React se synchronise avec un système externe, ou est-ce que j'utilise un Effet pour compenser un état dont je n'avais pas besoin ?

B-11

Où cette approche s'applique

Reconnaître les Effets inutiles et les remplacer par des patterns plus simples fait partie de la construction de bases de code React que les équipes peuvent posséder et faire évoluer avec confiance. La discipline de refactoring de cet article réduit les rendus superflus, simplifie le flux de données et rend le comportement des composants plus facile à raisonner.

Voir comment les fondations façonnent la livraison