Accueil / Articles / Arrêter la synchronisation de l’état avec useEffect : un pattern React plus sûr

Arrêter la synchronisation de l’état avec useEffect : un pattern React plus sûr

Découvrez pourquoi l’utilisation de useEffect pour synchroniser l’état dérivé provoque des conditions de course et des rendus supplémentaires, ainsi que la manière de le remplacer par une dérivation en temps de rendu et l’attribut key.

2523 mots

Comment traiter useEffect comme un mécanisme pour synchroniser l’état provoque des boucles de rendu, des conditions de course et des dysfonctionnements fantômes de l’interface utilisateur.

Un ticket de support arrive sur votre bureau, marqué comme urgent. Un client indique que, lors du passage d’un compte à un autre sur le tableau de bord d’équipe, le journal des activités affiche parfois des événements appartenant au compte qu’il avait consulté trente secondes plus tôt.

Vous examinez le code. Il n’y a rien d’exotique ici — pas de couche WebSocket, pas de threads de travail, juste une vue React classique type master-detail.

Lors des tests locaux, vous cliquez sur les comptes un par un. Les quatre-vingt-dix-neuf premiers changements fonctionnent correctement. Puis, lors de la centième tentative avec le ralentissement réseau activé, quelque chose se dérègle visuellement : pendant un bref instant, le niveau de facturation de l’utilisateur précédemment sélectionné apparaît dans la carte de profil de l’utilisateur nouvellement sélectionné avant de se corriger.

La cause de ce phénomène réside dans un schéma qui semble complètement inoffensif :

useEffect(() => {
  if (selectedUserId) {
    fetchUserData(selectedUserId).then((data) => {
      setUserProfile(data);
    });
  }
}, [selectedUserId]);

Cette habitude — recourir à useEffect pour maintenir l’état interne d’un composant en accord avec les props ou un autre état — provoque plus de bugs subtils, de ralentissements visuels et de problèmes structurels dans le code frontend moderne que presque tout autre schéma.

1. L’erreur de conception liée au cycle de vie : pourquoi les développeurs privilégient useEffect

Lorsque les Hooks sont arrivés dans React 16.8, les ingénieurs ayant une expérience avec les composants classiques ont souvent considéré useEffect comme un remplaçant direct de componentDidMount, componentDidUpdate et componentWillUnmount regroupés en une seule API.

Cette supposition a entraîné de véritables confusions par la suite.

Les composants de classe encourageaient un style impératif : lorsque une propriété changeait, il fallait déclencher manuellement this.setState() à l’intérieur de componentDidUpdate pour recalculer tout ce qui en dépendait.

Lors du passage aux composants fonctionnels, de nombreux développeurs ont conservé cette même habitude impérative, en supposant essentiellement que chaque fois qu’une propriété changeait, c’était à eux de mettre explicitement à jour une variable d’état locale correspondante.

Le problème, c’est que React est fondamentalement déclaratif et basé sur l’état. Écrire un useEffect dont le seul but est de mettre à jour une autre variable d’état locale force en fait React à effectuer deux passes de rendu complètes au lieu d’une seule.

Voici la séquence qui se déroule :

  1. React rend le composant en utilisant les nouvelles propriétés ainsi que l’état encore obsolète.
  • Cette rendu est enregistré dans le DOM, et le navigateur l’affiche.
  • L’effet est déclenché et appelle setState().
  • React met en file un deuxième rendu reflétant l’état mis à jour.
  • Prenons cet exemple :

    function UserBillingSummary({
      plan,
      addonCount
    }: {
      plan: string;
      addonCount: number;
    }) {
      const [totalCost, setTotalCost] = useState(0);
    
      useEffect(() => {
        const base = plan === 'enterprise' ? 499 : 99;
        setTotalCost(base + addonCount * 25);
      }, [plan, addonCount]);  return <div>Total: ${totalCost} / month</div>;
    }
    

    Ici, il n’y a absolument pas besoin d’une variable d’état distincte — totalCost peut être entièrement dérivé de plan et addonCount.

    En d’autres termes, le composant effectue un travail supplémentaire inutile simplement pour obtenir une valeur qui était déjà calculable lors du rendu initial.

    Pendant cette frame intermédiaire, les utilisateurs peuvent voir brièvement des chiffres incohérents à l’écran. De plus, si une logique de mise en page dépend de cette valeur calculée, le navigateur pourrait également devoir refaire le travail de mise en page et d’affichage qu’il n’aurait pas dû devoir répéter.

    2. L’effet domino : chaînes de dépendances en cascade

    Ce coût de rendu double s’aggrave considérablement dès que plusieurs effets commencent à dépendre des résultats les uns des autres.

    Imaginez un panneau de filtrage en plusieurs étapes à l’intérieur d’un tableau de bord analytique :

    function AnalyticsFilters({
      organizationId
    }: {
      organizationId: string;
    }) {
      const [teams, setTeams] = useState<Team[]>([]);
      const [selectedTeamId, setSelectedTeamId] = useState<string>('');
      const [projects, setProjects] = useState<Project[]>([]);
      const [selectedProjectId, setSelectedProjectId] = useState<string>('');
    
      useEffect(() => {
        fetchTeams(organizationId).then((res) => {
          setTeams(res);
          setSelectedTeamId(res[0]?.id || '');
        });
      }, [organizationId]);  useEffect(() => {
        if (selectedTeamId) {
          fetchProjects(selectedTeamId).then((res) => {
            setProjects(res);
            setSelectedProjectId(res[0]?.id || '');
          });
        }
      }, [selectedTeamId]);  useEffect(() => {
        if (selectedProjectId) {
          logAnalyticsFilterChange(selectedProjectId);
        }
      }, [selectedProjectId]);  return (
        <div className="filter-bar">
          {/* Filter UI */}
        </div>
      );
    }
    

    Observez ce qui se passe dès que organizationId change :

    1. React rérendu à nouveau en utilisant le nouveau organizationId.
    2. Le premier effet récupère la liste des équipes, puis met à jour à la fois teams et selectedTeamId.
    3. React rérendu à nouveau.
    4. Un deuxième effet remarque la valeur mise à jour de selectedTeamId et récupère les projets associés.
    5. React rérendu encore une fois.
    6. Un troisième effet prend en compte la nouvelle valeur de selectedProjectId et enregistre ce changement.

    Ce qui a commencé par une simple mise à jour d’une propriété s’est transformé en une chaîne de modifications d’état et d’exécutions d’effets.

    Au fur et à mesure que l’application grandit, de telles chaînes deviennent véritablement difficiles à tracer. Si les réponses du réseau arrivent dans le mauvais ordre, ou si l’une d’elles renvoie rien en raison d’un problème de permissions ou d’un autre cas particulier, l’interface peut tomber dans un état incohérent sans qu’aucune erreur évidente ne soit générée.

    Le véritable problème n’est pas seulement le nombre supplémentaire de rendus — c’est que le composant s’est silencieusement transformé en une mini-machine à états asynchrone que personne n’a conçue délibérément comme telle.

    3. Le fantôme des conditions de course asynchrones

    Les appels asynchrones non gérés à l’intérieur de useEffect constituent une autre source fréquente de données fantômes dans les applications single-page.

    Imaginez un agent de support qui clique rapidement sur différentes lignes de tickets dans un tableau :

    function TicketDetailView({
      ticketId
    }: {
      ticketId: string;
    }) {
      const [ticket, setTicket] = useState<TicketData | null>(null);
      const [loading, setLoading] = useState(true);
    
      useEffect(() => {
        setLoading(true);    api.getTicket(ticketId).then((data) => {
          setTicket(data);
          setLoading(false);
        });
      }, [ticketId]);  if (loading) return <div>Loading ticket...</div>;  return <TicketDetails ticket={ticket} />;
    }
    

    Voici la séquence d’événements qui détruit ce composant :

    1. L’agent clique sur le ticket n°101. La demande A est déclenchée.
    2. setTicket(ticket101).

    React n’est pas en cause ici. Le véritable problème réside dans le fait que le composant permet à une demande obsolète de remplacer l’état après que l’utilisateur a déjà navigué ailleurs.

    Si vous effectuez des requêtes à l’intérieur d’un effet sans aucune bibliothèque d’aide, vous devez prévenir cela en effectuant un nettoyage manuel :

    useEffect(() => {
      let isCurrent = true;
    
      setLoading(true);  api.getTicket(ticketId)
        .then((data) => {
          if (isCurrent) {
            setTicket(data);
            setLoading(false);
          }
        })
        .catch((err) => {
          if (isCurrent) {
            handleTicketError(err);
          }
        });  return () => {
        isCurrent = false;
      };
    }, [ticketId]);
    

    Une meilleure option, lorsque votre client API le prend en charge, est AbortController. Au lieu de simplement ignorer une réponse arrivée en retard, vous annulez réellement la requête en cours avant qu’elle ne puisse être traitée.

    4. L’alternative propre : état dérivé lors du rendu

    Souvent, la solution la plus simple aux bugs de synchronisation consiste à éviter de synchroniser l’état dès le départ.

    Dans de nombreux cas où les développeurs ont instinctivement recours à useState associé à useEffect, la valeur qu’ils tentent de stocker existe déjà quelque part dans les props actuels ou l’état du parent.

    Au lieu de dupliquer cette valeur dans un état local, vous pouvez simplement la calculer directement pendant le rendu.

    Voici le schéma problématique :

    function OrderSummary({
      items,
      discountCode
    }: OrderSummaryProps) {
      const [discountPercent, setDiscountPercent] = useState(0);
      const [subtotal, setSubtotal] = useState(0);
      const [finalTotal, setFinalTotal] = useState(0);
    
      useEffect(() => {
        const rawSum = items.reduce(
          (acc, item) => acc + item.price * item.quantity,
          0
        );    setSubtotal(rawSum);
      }, [items]);  useEffect(() => {
        const discount = calculateDiscount(discountCode);
        setDiscountPercent(discount);
      }, [discountCode]);  useEffect(() => {
        setFinalTotal(
          subtotal - subtotal * (discountPercent / 100)
        );
      }, [subtotal, discountPercent]);  return (
        <SummaryView
          subtotal={subtotal}
          total={finalTotal}
        />
      );
    }
    

    Comparez maintenant cela à une version basée sur un état dérivé :

    function OrderSummary({
      items,
      discountCode
    }: OrderSummaryProps) {
      const subtotal = items.reduce(
        (acc, item) => acc + item.price * item.quantity,
        0
      );
    
      const discountPercent = calculateDiscount(discountCode);  const finalTotal =
        subtotal - subtotal * (discountPercent / 100);  return (
        <SummaryView
          subtotal={subtotal}
          total={finalTotal}
        />
      );
    }
    

    Le contraste est important. Il n’y a pas d’état dupliqué, aucun mécanisme chargé de synchroniser les valeurs, et aucune fenêtre dans laquelle finalTotal pourrait dévier de l’alignement avec subtotal et discountPercent.

    Les props eux-mêmes restent la seule source de vérité à tout moment.

    Lorsqu’un calcul est réellement coûteux, useMemo vous permet de mettre en cache le résultat entre les rendus :

    const filteredTransactions = useMemo(() => {
      return rawTransactions.filter((tx) => {
        return (
          tx.amount >= minThreshold &&
          tx.category === activeCategory
        );
      });
    }, [rawTransactions, minThreshold, activeCategory]);
    

    Le point clé est que useMemo existe uniquement pour mémoriser un calcul — il n’est pas conçu pour synchroniser deux états distincts entre eux.

    5. Réinitialiser l’état de manière déclarative avec la prop key

    Un piège similaire apparaît lorsque un formulaire modifiable doit réinitialiser ses champs chaque fois que l’entité en cours de modification change.

    L’instinct typique est le suivant :

    function EditUserModal({
      user
    }: {
      user: UserData;
    }) {
      const [name, setName] = useState(user.name);
      const [role, setRole] = useState(user.role);
    
      useEffect(() => {
        setName(user.name);
        setRole(user.role);
      }, [user.id]);  return (
        <form>
          <input
            value={name}
            onChange={(e) => setName(e.target.value)}
          />      <select
            value={role}
            onChange={(e) => setRole(e.target.value)}
          />
        </form>
      );
    }
    

    Cette approche peut provoquer un éclairage visible, où les données du record précédent restent affichées pendant une mise à l’écran avant que l’effet ne s’active et n’actualise les champs d’entrée.

    Pire encore, elle peut écraser silencieusement ce que l’utilisateur était en train de taper si de nouvelles données arrivent au milieu de la saisie.

    React dispose déjà d’une solution déclarative intégrée pour cela : la propriété key.

    Dans le composant parent :

    function UserAdminPage() {
      const [selectedUser, setSelectedUser] =
        useState<UserData | null>(null);
    
      return (
        <div>
          <UserList onSelectUser={setSelectedUser} />      {selectedUser && (
            <EditUserForm
              key={selectedUser.id}
              initialUser={selectedUser}
            />
          )}
        </div>
      );
    }
    

    Le état local du composant enfant reste alors simple, sans rien à concilier :

    function EditUserForm({
      initialUser
    }: {
      initialUser: UserData;
    }) {
      const [name, setName] = useState(initialUser.name);
      const [role, setRole] = useState(initialUser.role);
    
      return (
        <form>
          <input
            value={name}
            onChange={(e) => setName(e.target.value)}
          />      <select
            value={role}
            onChange={(e) => setRole(e.target.value)}
          />
        </form>
      );
    }
    

    Lorsque la key passe de user-1 à user-2, React ne tente pas de mettre à jour le composant existant — il le supprime et monte une nouvelle instance, avec un état initialisé à partir des nouvelles données de l’utilisateur.

    Aucun effet de synchronisation n’est nécessaire du tout.

    6. Où se trouve réellement le code : gestionnaires d’événements vs. effets

    Imaginez que vous ayez besoin de déclencher un événement d’analyse et d’afficher une notification de confirmation chaque fois que quelqu’un clique sur « Soumettre la commande ».

    Une façon d’écrire cela :

    function CheckoutButton({
      orderId
    }: {
      orderId: string;
    }) {
      const [submitted, setSubmitted] = useState(false);
    
      useEffect(() => {
        if (submitted) {
          analytics.track('order_submitted', {
            orderId
          });      showToast('Order placed successfully!');
        }
      }, [submitted, orderId]);  return (
        <button onClick={() => setSubmitted(true)}>
          Place Order
        </button>
      );
    }
    

    Le problème, c’est que cela sépare le déclencheur de l’action — le clic définit un indicateur, et un effet réagit à cet indicateur plus tard.

    Une approche plus propre consiste à garder tout cela directement dans le gestionnaire d’événements lui-même :

    function CheckoutButton({
      orderId
    }: {
      orderId: string;
    }) {
      const handlePlaceOrder = async () => {
        await submitOrderApi(orderId);
    
        analytics.track('order_submitted', {
          orderId
        });    showToast('Order placed successfully!');
      };  return (
        <button onClick={handlePlaceOrder}>
          Place Order
        </button>
      );
    }
    

    Désormais, la causalité est explicite : l’utilisateur clique, la commande est soumise, et les actions suivantes s’exécutent immédiatement dans le cadre du même événement. Il n’y a aucun changement d’état intermédiaire permettant de détecter et de réagir à un effet distinct.

    7. Quand useEffect est-il vraiment justifié ?

    Tout cela ne signifie pas pour autant que useEffect lui-même soit défectueux.

    Les problèmes commencent lorsque cet outil est utilisé comme solution universelle pour transférer des données entre différentes parties de l’état React.

    Sa véritable fonction est de maintenir votre composant en synchronisation avec quelque chose qui se trouve en dehors du modèle de rendu propre à React.

    Ce « quelque chose en dehors de React » relève généralement de catégories telles que :

    • Les API natives du navigateur, comme window.addEventListener, IntersectionObserver ou matchMedia
  • Bibliothèques tierces impératives, telles que Mapbox, Chart.js ou des SDK de lecteurs vidéo
  • Connexions en temps réel comme WebSockets ou Server-Sent Events
  • Maniпulation directe du DOM, comme la mise à jour de document.title
  • Le suivi de la largeur de la zone de visualisation du navigateur lors des redimensionnements est un bon exemple d’effet légitime :

    function useWindowWidth() {
      const [width, setWidth] = useState(
        () => window.innerWidth
      );
    
      useEffect(() => {
        const handleResize = () => {
          setWidth(window.innerWidth);
        };    window.addEventListener(
          'resize',
          handleResize
        );    return () => {
          window.removeEventListener(
            'resize',
            handleResize
          );
        };
      }, []);  return width;
    }
    

    Dans ce cas, l’effet consiste à effectuer quelque chose que la seule rendu ne peut pas accomplir : il s’agit d’établir une abonnement à un événement du navigateur puis de le désactiver lors du nettoyage.

    C’est précisément le type de tâche pour laquelle useEffect a été conçu.

    Les règles architecturales que je suis désormais

    Lorsqu’un tableau de bord ou une application basée sur React commence à être lent, imprévisible ou riche en erreurs de synchronisation difficiles à reproduire, le premier endroit à vérifier est la manière dont useEffect est utilisé dans l’ensemble du code.

    Quatre principes directeurs permettent généralement de résoudre la plupart des problèmes.

    1. Calculer les valeurs pendant le rendu. Tout ce qui peut être dérivé des props ou de l’état existant doit être calculé directement dans le corps du rendu. N’utilisez useMemo que lorsque ce calcul est réellement coûteux.

    2. Conserver la logique déclenchée par l’utilisateur à l’intérieur des gestionnaires d’événements. Lorsqu’un événement tel qu’un clic, une touche, une sélection ou une soumission se produit, cette logique doit être placée juste à côté de cet événement, et non dispersée dans un effet distinct.

    3. Réinitialiser l’état proprement avec la propriété key. Lors du passage d’une entité à une autre, il faut générer une instance de composant complètement nouvelle ; laissez React rémonter le composant plutôt que de synchroniser manuellement chaque champ individuel.

    4. Réservez useEffect à une véritable synchronisation externe. Les événements du navigateur, les abonnements, les connexions WebSocket et les intégrations impératives avec des bibliothèques constituent le domaine approprié pour les effets.

    L’objectif n’est pas d’éliminer complètement useEffect de votre codebase.

    L’objectif est d’arrêter à utiliser ce mécanisme comme un canal informel pour transférer des valeurs entre différentes parties de l’état React.

    Lorsque les valeurs dérivées sont traitées comme telles, que les actions de l’utilisateur sont gérées en tant qu’événements et que seuls les systèmes externes réels sont connectés via des effets, il devient beaucoup plus facile de comprendre les composants React.

    De plus, une grande partie des bugs mystérieux qui ne se manifestent qu’après des dizaines de clics, sur une connexion réseau lente ou exclusivement en production deviennent bien plus faciles à prévenir dès le départ.

    Lectures complémentaires

  • Activer le soutien hors ligne dans les applications web avec Service Workers — Découvrez comment utiliser Service Workers et l’API Cache pour faire en sorte qu’un site web charge instantanément et continue de fonctionner même sans connexion Internet.
  • Explication du rendering de React : mises à jour d’état vers les pixels de l’écran — Apprenez comment les phases de rendering, de réconciliation et de commit de React se connectent au pipeline de mise en page, de peinture et de composition du navigateur pour générer des pixels.
  • Résoudre les conditionnes de concurrence : le débouncing ne suffit pas dans les interfaces de recherche — Comprenez pourquoi le débouncing seul ne peut pas empêcher que des réponses API obsolètes écrasent l’état de l’interface, et découvrez quatre solutions pratiques pour garantir un ordre correct des requêtes.
  • Pourquoi les callbacks de useEffect dans React ne devraient jamais être des fonctions asynchrones — Découvrez pourquoi renvoyer une fonction asynchrone depuis useEffect viole le contrat de nettoyage de React, et découvrez quatre modèles corrects pour gérer la logique asynchrone en toute sécurité.