Accueil / Articles

Pourquoi les fonctions de rappel useEffect de React ne doivent jamais être des fonctions asynchrones

Une fonction asynchrone passée à useEffect viole le contrat de nettoyage de React car elle renvoie une Promise. Quatre modèles corrects pour le travail asynchrone à l’intérieur des effects.

Éditeur : REACTAPP.TOP14 min de lecture3106 mots

Sources: React Interview Question Asked in Paytm

Cette question est apparue lors d’un entretien frontend chez Paytm, et elle mérite plus d’attention que sa simplicité ne le laisserait penser. À première vue, elle semble être un petit piège de syntaxe. En réalité, elle vise à évaluer si vous comprenez le cycle de vie des effets, le comportement réel des Promises en JavaScript, ce que React fait internement avec les résultats fournis par vos effets, ainsi que la manière correcte de récupérer des données à l’intérieur d’un composant.

La plupart des candidats donnent une réponse techniquement correcte, mais qui ne va qu’en surface :

« useEffect attend que vous retourniez une fonction de nettoyage, tandis qu’une fonction asynchrone retourne plutôt une Promise. »

C’est une introduction raisonnable, mais elle laisse les questions intéressantes sans réponse. Que fait React de cette Promise une fois qu’il la reçoit ? La jette-t-il simplement ? Qu’est-ce qui se brise réellement par conséquent ? Et, plus utilement encore, comment devriez-vous structurer la logique asynchrone à l’intérieur d’un effect pour que rien ne se brise ?

Construisons l’ensemble de la situation morceau par morceau, en vérifiant chaque affirmation contre la documentation React ainsi que le code source de React lui-même.

Partie 1 : Ce à quoi s’attend le contrat useEffect

La fonction passée dans useEffect est officiellement appelée la fonction de configuration, et la documentation React est stricte quant à ce qu’elle est autorisée à retourner. Il n’y a que deux résultats acceptables :

  • Ne rien retourner, ce qui signifie que la valeur est undefined, ou
  • Retourner une fonction de nettoyage que React appellera ultérieurement.
useEffect(() => {
  // setup: runs after the component is committed to the screen
  const id = setInterval(tick, 1000);
  // cleanup: React calls this before the next setup run,
  // and one final time when the component unmounts
  return () => clearInterval(id);
}, []);

Ce fonction de nettoyage n’est pas simplement utile : c’est le mécanisme sur lequel React s’appuie pour démanteler tout ce que la phase de configuration a créé, qu’il s’agisse d’un timer, d’une abonnement, d’un écouteur d’événements ou d’une connexion WebSocket ouverte. Selon la documentation de React, ce dernier appelle votre fonction de nettoyage juste avant de réexécuter l’effet, puis une fois de plus lorsque le composant est définitivement démonté.

C’est ici que réside l’importance de cette question : React a besoin de cette fonction de nettoyage immédiatement, dès que votre fonction de configuration a terminé son exécution. Parfois, le nettoyage doit avoir lieu presque aussitôt, potentiellement avant même que le premier await à l’intérieur d’une fonction asynchrone ne soit résolu. Une fonction de nettoyage qui apparaît « finalement » est une fonction que React ne peut pas utiliser.

Partie 2 : Pourquoi async signifie toujours Promise

Cette section concerne uniquement le comportement du JavaScript pur, sans aucune exception. Selon MDN, les fonctions déclarées avec async retournent toujours une Promise, point final. Même si vous retournez quelque chose qui n’est pas déjà une Promise, JavaScript l’enveloppe silencieusement dans une Promise pour vous. Il n’existe aucune syntaxe permettant de contourner cela.

async function getCleanup() {
  return () => console.log('cleaning up');
}
const result = getCleanup();console.log(result);
// Promise { <fulfilled>: f }   <- the function is trapped insideconsole.log(typeof result);
// "object"   <- not "function"

Examinez de près ce qui s’est passé. Le code retourne explicitement une fonction flèche, et pourtant l’appelant ne reçoit pas de fonction. Au lieu de cela, il obtient une Promise qui finira par se résoudre en cette fonction. Cela vaut également pour une fonction async ne contenant aucune instruction return — elle retourne toujours une Promise, celle-ci se résolvant en undefined.

Associez les deux éléments :

  1. React attend soit undefined, soit une fonction réelle, fournie de manière synchrone.
  • Une fonction asynchrone fournit toujours une Promise, quel que soit le cas.
  • Ainsi, le contrat est déjà violé avant même que toute logique d’effet n’ait la chance de s’exécuter.

    // What you think you wrote:
    useEffect(async () => {
      const data = await fetchUser(id);
      setUser(data);
      return () => controller.abort(); // "my cleanup"
    }, [id]);
    
    // What React receives from your setup function:
    // Promise { <pending> }
    // Your cleanup is locked inside a box React never opens.
    

    Partie 3 : Ce que React fait réellement avec la valeur retournée

    C’est là que de nombreuses explications se trompent. On dit souvent que « React ignore simplement la Promise que vous retournez ». Le code source raconte une histoire différente. En réalité, React effectue trois actions distinctes avec cette valeur retournée, et deux d’entre elles causent de vrais problèmes.

    3.1 Il stocke la Promise comme étape de nettoyage

    Au cœur du mécanisme de réconciliation, plus précisément dans commitHookEffectListMount — la fonction chargée d’exécuter vos effets après un commit — React appelle votre fonction setup et stocke directement sa valeur de retour, sans poser de questions. Dans une version destinée à la production, il n’y a aucune vérification en temps de exécution pour détecter un type incohérent :

    // react-reconciler/src/ReactFiberCommitEffects.js (simplified)
    const create = effect.create;   // your setup function
    const destroy = create();       // whatever it returns...
    inst.destroy = destroy;         // ...is stored as the cleanup
    

    Si la fonction setup que vous avez fournie est asynchrone, inst.destroy contient alors une Promise en attente à l’endroit exact où React s’attend à trouver une fonction de nettoyage exécutable.

    3.2 La version de développement affiche un avertissement très spécifique

    Lorsque la valeur est stockée, les builds de développement effectuent une étape de validation supplémentaire. React vérifie si typeof destroy !== 'function', et va même au-delà d’une simple vérification de type : il cherche spécifiquement une méthode .then sur la valeur retournée. C’est un indice clair indiquant que vous lui avez fourni une Promise plutôt qu’une fonction, et React le mentionne directement :

    useEffect must not return anything besides a function, which is used for
    clean-up.
    
    It looks like you wrote useEffect(async () => ...) or returned a Promise.
    Instead, write the async function inside your effect and call it immediately:useEffect(() => {
      async function fetchData() {
        // You can await here
        const response = await MyAPI.getData(someId);
        // ...
      }
      fetchData();
    }, [someId]); // Or [] if effect doesn't need props or stateLearn more about data fetching with Hooks: https://react.dev/link/hooks-data-fetching
    

    Remarquez que l’avertissement ne se contente pas de signaler un problème — il vous propose également la solution directement dans son message, en montrant le schéma pour déclarer une fonction asynchrone à l’intérieur de l’effet et l’appeler immédiatement. Lorsque le texte d’erreur fourni par un framework contient déjà la solution, c’est généralement un signe fort que les interviewers s’attendent à ce que vous l’ayez déjà vu.

    3.3 Lorsque vient le moment du nettoyage, React tente d’appeler la Promise

    C’est ici que les choses deviennent véritablement dangereuses. Lorsque les dépendances de l’effet changent, ou que le composant est désinstallé, la logique de désinstallation de React ne vérifie que si quelque chose a été enregistré comme opération de nettoyage — elle ne contrôle jamais si la valeur enregistrée est appelable :

    // commitHookEffectListUnmount (simplified)
    const destroy = inst.destroy;
    if (destroy !== undefined) {
      safelyCallDestroy(effect, ...); // executes destroy()
    }
    
    // destroy is a Promise here, and a Promise is not callable:
    // TypeError: destroy is not a function
    

    Tenter d’appeler une Promise comme s’il s’agissait d’une fonction provoque un TypeError. C’est exactement ce qui a causé la célèbre panne « Uncaught TypeError: destroy is not a function » dans React 17. Dans les versions actuelles de React, cette appelation est encadrée par un mécanisme de protection ; ainsi, au lieu que toute la page plante, l’erreur est capturée en tant qu’erreur de phase de commit et transmise au bord d’erreur le plus proche. Dans tous les cas, il s’agit d’une véritable défaillance en temps de exécution, et non simplement d’un message dans la console que l’on peut ignorer.

    En résumé, on se retrouve face à trois défaillances distinctes :

    1. Votre code de nettoyage réel n’est jamais exécuté, donc les temporiseurs, les écouteurs d’événements et les abonnements ne sont jamais désactivés.
    2. React enregistre un avertissement pendant le développement.
    3. Plus tard, lorsque React tente d’exécuter la fonction de nettoyage, il essaie d’appeler une Promise comme si c’était une fonction et génère une erreur.

    Partie 4 : Comparaison étape par étape des deux chemins d’exécution

    Il est utile de comparer une fonction de configuration synchrone avec une asynchrone, moment par moment. Les trois premières étapes sont identiques. Tout diverge à partir de la quatrième étape.

    Version synchrone :

    1. La fonction du composant s’exécute et le JSX est calculé.
    2. React enregistre la mise à jour, et le navigateur affiche l’interface.
    3. React appelle votre fonction de configuration.
  • La fonction renvoie une véritable fonction de nettoyage, que React stocke.
  • Toute logique asynchrone à l’intérieur de l’effet continue de s’exécuter, et les mises à jour d’état s’appliquent sans problème.
  • Lorsque les dépendances changent ou que le composant est désinstallé, React appelle la fonction cleanup() stockée.
  • Le nettoyage annule l’intervalle, supprime l’écouteur ou met à jour le flag d’ignorance — rien ne s’échappe.
  • Version asynchrone :

    1. La fonction du composant s’exécute et le JSX est calculé.
    2. React enregistre la mise à jour, et le navigateur affiche le contenu.
    3. React invoque votre fonction de configuration, qui — étant asynchrone — renvoie immédiatement.
    4. Ce qui est retourné est un Promise { <pending> }, et React stocke ce Promise comme s’il s’agissait de la fonction de nettoyage. En mode développement, c’est ici que l’avertissement est déclenché.
    5. Vos appels await sont toujours en cours d’exécution en arrière-plan, et toute logique de nettoyage que vous avez écrite se trouve à l’intérieur de cette Promise non résolue, donc inatteignable.
    6. Lorsque les dépendances changent ou que le composant est désinstallé, React tente d’appeler la valeur stockée — qui n’est pas une fonction — et génère un TypeError.
    7. Aucun nettoyage n’a jamais eu lieu. Les intervalles continuent de fonctionner, et des réponses obsolètes peuvent encore déclencher setState.

    Cette comparaison éclaire également pourquoi le contrat exige quelque chose de synchrone. Les effets s’exécutent après l’envoi du commit, une fois que le rendu a déjà eu lieu, et le nettoyage relatif à l’effet précédent doit se dérouler juste avant que le prochain effet ne s’exécute — ce qui peut être dès le rendu suivant. Si React devait attendre une Promise simplement pour déterminer s’une fonction de nettoyage existe, toute la séquence d’envoi des commits devrait être suspendue en raison de code utilisateur arbitraire, ce qui est contraire au fonctionnement prévu pour la phase d’envoi des commits. C’est là la véritable raison pour laquelle l’API a été conçue autour de retours synchrones plutôt que pour apprendre à React à attendre ses Promises.

    Partie 5 : La condition de course cachée par ce schéma

    Il existe un deuxième danger, plus subtil, lié au pattern asynchrone : en l’absence de fonction de nettoyage, on perd toute possibilité d’annuler ou d’ignorer les tâches asynchrones obsolètes. La documentation officielle de React illustre ce phénomène à l’aide d’un scénario de page de profil, et il est essentiel de bien le comprendre, car il ne s’agit pas d’une simple remarque stylistique — c’est une erreur qui peut être intégrée en production.

    Voici comment cela se déroule :

    1. Vous cliquez sur « Bob », et l’application commence à récupérer sa biographie.
    2. Avant que cette opération ne soit terminée, vous cliquez sur « Taylor », ce qui déclenche la récupération de sa biographie.
    3. La réponse de Taylor arrive en premier, ce qui fait que l’interface affiche sa biographie.
    4. La réponse de Bob, plus lente, arrive par la suite et écrase l’état actuel.
    5. À la fin, l’écran affiche Taylor comme utilisateur sélectionné, mais avec la biographie de Bob en dessous.

    Puisque les réponses du réseau ne sont pas garanties d’être traitées dans l’ordre où les requêtes ont été envoyées, ce type de désalignement représente un risque réel. La solution standard consiste en une fonction de nettoyage qui active un drapeau ignore, vous permettant d’ignorer toutes les réponses sauf celle liée à la dernière rendu. Cette solution ne fonctionne que si votre fonction de configuration renvoie de manière synchrone, fournissant ainsi à React une vraie fonction de nettoyage à exécuter.

    Ainsi, la fonction de rappel asynchrone n’est pas simplement une violation technique du contrat API — elle élimine justement le mécanisme qui empêche cette condition de concurrence d’affecter vos utilisateurs.

    Partie 6 : Les modèles corrects

    Toute solution valide repose sur la même idée : garder la fonction de configuration elle-même synchrone et déléguer le travail asynchrone à quelque chose que cette fonction appelle. De cette façon, React obtient toujours une véritable fonction de nettoyage.

    Pattern 1 : Une fonction asynchrone nommée à l’intérieur de l’effet (la recommandation officielle)

    C’est l’approche présentée dans la documentation de React, et elle inclut le drapeau ignore qui neutralise la condition de course décrite ci-dessus :

    useEffect(() => {
      let ignore = false;
    
      async function startFetching() {
        const json = await fetchTodos(userId);
        if (!ignore) {
          setTodos(json);
        }
      }  startFetching();  return () => {
        ignore = true;
      };
    }, [userId]);
    

    La fonction flèche externe ne touche jamais à await, donc return () => { ignore = true; } fournit à React une véritable fonction de nettoyage à conserver. La fonction nommée interne, quant à elle, peut attendre ce dont elle a besoin librement.

    Pattern 2 : Une IIFE asynchrone

    Cela fait la même chose, mais avec une fonction anonyme qui s’exécute immédiatement plutôt qu’une fonction nommée :

    useEffect(() => {
      let ignore = false;
    
      (async () => {
        const data = await fetchUser(id);
        if (!ignore) setUser(data);
      })();  return () => { ignore = true; };
    }, [id]);
    

    Pattern 3 : Chaînes simples de .then()

    La documentation de référence de React utilise en fait ce style dans son propre exemple de récupération de données :

    useEffect(() => {
      let ignore = false;
      setBio(null);
      fetchBio(person).then(result => {
        if (!ignore) {
          setBio(result);
        }
      });
      return () => {
        ignore = true;
      };
    }, [person]);
    

    Pattern 4 : AbortController, lorsque vous souhaitez une annulation réelle

    Le drapeau ignore empêche une réponse obsolète de modifier l’état, mais il ne stoppe pas la requête elle-même de se terminer en arrière-plan. Lorsque vous utilisez fetch, vous pouvez aller plus loin et annuler directement l’appel réseau. Les documents de React indiquent clairement que le nettoyage doit soit annuler la requête fetch, soit ignorer son résultat :

    useEffect(() => {
      const controller = new AbortController();
    
      (async () => {
        try {
          const res = await fetch(`/api/users/${id}`, {
            signal: controller.signal,
          });
          setUser(await res.json());
        } catch (e) {
          if (e.name !== 'AbortError') throw e;
        }
      })();  return () => controller.abort();
    }, [id]);
    

    Lequel de ces points devriez-vous aborder en premier lors d’un entretien ? Commencez par le Modèle 1 — c’est celui que soulignent à la fois l’avertissement du mode de développement de React et les documents officiels. Mentionnez explicitement le drapeau ignore, puis ajoutez que, pour les appels fetch en particulier, il convient d’aller plus loin en utilisant AbortController pour annuler la requête plutôt que de simplement ignorer son résultat. Cette combinaison — précision, conditions concurrentes et trafic réseau inutile — peut être expliquée en trois phrases et couvre l’ensemble du problème.

    Partie 7 : StrictMode et TypeScript détectent également cela

    StrictMode. Lors du développement, StrictMode exécute délibérément un cycle supplémentaire de configuration puis de nettoyage juste après le montage initial. Selon la documentation React, cela vise à tester si votre logique de nettoyage reflète réellement votre logique de configuration. Une action asynchrone échoue inévitablement à ce test en raison de sa nature même, puisqu’elle ne restitue jamais une fonction de nettoyage utilisable. Cette double invocation forcée est précisément ce qui révèle des abonnements dupliqués, des intervalles dupliqués et des requêtes fetch dupliquées, dont les deux réponses finissent par être enregistrées dans l’état.

    TypeScript. Les définitions de types pour React intégrent directement ce contrat dans le système de types :

    // From @types/react (simplified)
    type Destructor = () => void;
    type EffectCallback = () => void | Destructor;
    
    function useEffect(effect: EffectCallback, deps?: DependencyList): void;
    

    () => Promise<void>, et ce type ne peut tout simplement pas être assigné à EffectCallback. Ainsi, dans un projet TypeScript, cette erreur n’arrive même jamais à passer la compilation. Cela mérite d’être noté : cette restriction est suffisamment fondamentale pour être appliquée en trois endroits distincts — le système de types, l’avertissement en mode développement, et le comportement réel au moment de l’exécution.

    Partie 8 : Alternatives modernes à mentionner

    • Chargeurs au niveau du framework. Si le projet utilise un framework comme Next.js ou Remix, son mécanisme intégré de récupération de données est préférable aux effets côté client.
    • Bibliothèques de cache côté client. La documentation React mentionne spécifiquement TanStack Query, useSWR et React Router 6.4+. Celles-ci gèrent déjà la déduplication, le cache ainsi que les conditions de concurrence évoquées ci-dessus.
  • L’API use introduite dans React 19. use(promise) permet à un composant de lire directement la valeur résolue d’une Promise, en suspendant le composant tant que la Promise est en attente et en permettant à Suspense d’afficher un contenu de remplacement. Une précaution importante à mentionner si on nous le demande : une Promise créée lors du rendu est recréée à chaque rendu, donc use est conçu pour utiliser des promesses transmises depuis des composants serveur ou gérées par un cache — et non une appelation brute de fetch() écrite directement dans le code.
  • Rien de tout cela ne remplace l’explication fondamentale. La question concernant le contrat d’effet vise en réalité à vérifier si vous comprenez ce que React fait en interne avec la valeur retournée. Mais conclure par quelque chose comme « et c’est pourquoi, pour le chargement de données réelles, il vaut mieux utiliser TanStack Query ou un chargeur de framework » montre que vous connaissez également quelles sont les meilleures pratiques actuelles.

    La réponse pour l’interview en 30 secondes

    Si vous devez retenir une seule version de cette réponse, c’est bien celle-ci :

    « La fonction de configuration de useEffect respecte un contrat synchrone : elle doit retourner soit rien, soit une fonction de nettoyage, car React a besoin que cette fonction de nettoyage soit prête à être exécutée juste avant que l’effet ne s’exécute à nouveau et chaque fois que le composant est démonté. Une fonction asynchrone ne peut jamais répondre à cette exigence, car les fonctions asynchrones renvoient toujours une Promise, quel que soit le code qu’elles contiennent. React stocke alors cette Promise à l’endroit où devrait se trouver une fonction de nettoyage : en mode développement, il affiche l’avertissement “Il semble que vous ayez écrit useEffect(async () => …)”, la véritable logique de nettoyage n’a jamais l’occasion d’exécuter, et plus tard, lorsque React tente d’appeler la valeur stockée comme s’il s’agissait d’une fonction de nettoyage, un TypeError est levé. En pratique, cela se traduit par des abonnements non gérés et des conditions de concurrence, car sans fonction de nettoyage fonctionnelle, il n’y a aucun moyen d’ignorer ou d’annuler une requête obsolète. La solution consiste à conserver »

    La fonction de rappel elle-même doit être synchrone : on définit une fonction asynchrone à l’intérieur, on invoque cette fonction, puis on retourne une procédure de nettoyage qui soit met la flag d’ignorance à true, soit déclenche la méthode abort sur un AbortController. Pour le chargement de données dans le monde réel, un chargeur de framework ou une bibliothèque comme TanStack Query est un meilleur choix, car ceux-ci gèrent déjà automatiquement ces conditions de concurrence.

    Cette réponse aborde le contrat, la règle JavaScript sous-jacente, les différents modes d’échec concrets, la condition de concurrence, la solution reconnue, ainsi que l’évolution de l’écosystème moderne. Elle montre à un intervieweur que le concept n’a pas été simplement mémorisé comme une règle — son mécanisme est compris, ce qui est précisément ce que cette question vise à révéler.

    Lectures complémentaires