Accueil / Articles / Annuler les tâches obsolètes dans React : AbortController pour le changement de suivi

Annuler les tâches obsolètes dans React : AbortController pour le changement de suivi

Découvrez pourquoi les pistes sautées et les requêtes obsolètes continuent d’afficher du contenu dans votre interface utilisateur, comment AbortController annule la vraie requête, et en quoi il complète debounce et throttle dans React.

1747 mots

Lorsqu’un utilisateur change d’avis plus rapidement que le réseau ne peut répondre, toute requête déjà lancée continue de fonctionner et affichera son résultat sur l’écran. Dans une boîte de recherche, cela signifie des résultats pour une requête que l’utilisateur a abandonnée ; dans un lecteur multimédia, cela correspond à une brève diffusion de la chanson qu’il vient de sauter. L’attente ou la limitation des fréquences ne résolvent pas ce problème, car le traitement est déjà en cours. Cet article montre comment AbortController annule réellement ce traitement, comment l’intégrer dans un effet React, et comment il s’insère en complément de debounce et throttle plutôt que de les remplacer.

La course : les réponses arrivent dans le mauvais ordre

Prenez un champ de recherche. L’utilisateur tape « ni », et une requête est envoyée. Il tape ensuite « ke », ce qui déclenche une deuxième requête pour « nike ». Si le serveur met plus de temps à traiter la première requête, plus courte, sa réponse arrive après celle-ci et remplace les résultats corrects par des données obsolètes.

Un lecteur audio suit le même schéma, mais avec des conséquences plus importantes. En appuyant sur une piste, son chargement commence. En appuyant sur une autre piste avant que la première ne soit prête, un deuxième chargement est initié, et les deux tentent alors de s’affronter. Pendant un instant, l’auditeur peut entendre la piste sautée, la barre de progression peut afficher une durée incorrecte, ou les deux pistes peuvent essayer de prendre le contrôle du lecteur en même temps.

Le débouncing ne serait d’aucune aide ici. Il diffère le début du traitement jusqu’à ce que l’entrée soit stabilisée ; il ne fait rien pour une requête qui est déjà en cours. Ce qui manque, c’est la capacité d’annulation : indiquer à une tâche déjà commencée de s’arrêter.

Que se passe-t-il en l’absence d’annulation

fetch, les flux, le chargement des médias et les écouteurs d’événements représentent tous des tâches qui se poursuivent automatiquement une fois lancées. En l’absence de moyen de les arrêter, on observe généralement trois types de problèmes :

  • Des données obsolètes prennent le dessus. Une réponse plus ancienne est traitée après une nouvelle, remplaçant ce qui apparaît à l’écran ou faisant commencer la lecture dans un lecteur.
  • Ressources gaspillées. La bande passante, la batterie, le CPU du serveur et les requêtes CDN sont tous utilisés pour du contenu que personne ne verra ni n’entendra.
  • Une interface perturbée. Des indicateurs de chargement qui ne s’arrêtent jamais, une forme d’onde appartenant à la chanson précédente, un titre qui change deux fois rapidement.

La solution classique consiste à utiliser une vérification comme if (requestId !== latestId) return à l’intérieur de chaque callback, ou à ignorer silencieusement le résultat dans un .then(). Cela ne fonctionne que si chaque callback se souvient de cette vérification, et que la requête termine néanmoins son exécution et télécharge ses données. AbortController va plus loin en annulant l’opération elle-même plutôt que simplement de désactiver l’intérêt pour son résultat.

Le modèle de base d’AbortController

signal. Vous transmettez ce signal à toute API qui en accepte un, et l’appel de abort() sur le contrôleur indique à tous ceux qui détiennent ce signal d’arrêter :

const controller = new AbortController();

fetch("/api/tracks/123", { signal: controller.signal })
  .then((res) => res.json())
  .then((track) => loadIntoPlayer(track))
  .catch((err) => {
    if (err.name === "AbortError") {
      // expected. they picked a different song.
      return;
    }
    throw err;
  });

// they skipped, or left the page, or closed the player
controller.abort();

Trois points méritent une attention particulière. Premièrement, le signal est transmis dans les options de fetch, ce qui permet à fetch de savoir qu’il peut être annulé. Deuxièmement, l’annulation fait en sorte que la promesse soit rejetée avec une erreur nommée AbortError, même si les en-têtes sont déjà arrivés et que res.json() continue de lire le corps. Troisièmement, le bloc catch traite cette erreur comme une sortie normale et attendue, puis relance toutes les autres exceptions, de sorte que les véritables échecs ne sont pas absorbés.

Toute cette technique repose sur un cycle de vie simple : un contrôleur par unité d’intention. Lorsque l’utilisateur choisit une nouvelle piste, il faut annuler le contrôleur existant et en créer un nouveau pour la nouvelle charge. Un contrôleur ne peut pas être réinitialisé après avoir été annulé, donc son réutilisation entre différentes requêtes annulerait immédiatement les opérations futures.

Si vous appelez abort(reason) avec une raison personnalisée, fetch rejette la requête en utilisant cette raison au lieu de l’erreur par défaut AbortError. Dans ce cas, vérifier controller.signal.aborted est un moyen plus fiable pour distinguer une annulation intentionnelle d’une véritable panne.

Lier l’annulation à un effet React

Dans React, l’endroit naturel pour effectuer l’appel d’annulation est la fonction de nettoyage des effets. Lorsque la valeur qui déclenche une requête provient de props ou d’état, cette requête doit exister exactement aussi longtemps que cette valeur :

useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/tracks/${trackId}`, { signal: controller.signal })
    .then((res) => res.json())
    .then(setTrack)
    .catch((err) => {
      if (err.name === "AbortError") return;
      setError(err);
    });

  return () => controller.abort();
}, [trackId]);

Lorsque trackId change, React exécute le nettoyage lié à la rendu précédent avant de lancer l’effet suivant, ce qui entraîne l’abandon de la requête en cours pour l’ancienne piste et ne permet le démarrage de la nouvelle qu’à ce moment-là. Lorsque le lecteur est démonté, le même processus de nettoyage s’exécute, ce qui signifie qu’une réponse arrivée en retard ne peut jamais appeler setTrack sur un composant qui n’existe plus.

Lors du développement en mode strict, React monte, nettoie et réexécute intentionnellement les effets une fois. Avec ce schéma, vous verrez une requête annulée dans le panneau réseau ; il s’agit du nettoyage qui accomplit sa fonction, et la protection AbortError empêche qu’elle ne soit affichée comme une erreur.

Le même signal peut gérer plus d’une opération fetch. Les flux le prennent en charge, de même que les bibliothèques qui encapsulent XHR, et addEventListener accepte une option signal qui supprime l’écouteur lorsque le signal est interrompu. Une précaution pour les lecteurs : un élément <audio> simple ne prend pas en charge de signal. Pour empêcher son mémoire tampon de conserver un fichier sauté, il faut également supprimer ou remplacer son attribut src lors du nettoyage.

Debounce, throttle et abort résolvent des problèmes différents

Ces trois outils apparaissent souvent ensemble autour des champs de recherche et des commandes de lecture, ce qui les rend facilement confondables. Chacun agit à un moment différent :

  • Debounce attend que l’utilisateur fasse une pause, puis exécute l’action une seule fois. Si l’on tape rapidement « n-i-k-e », une seule requête sera envoyée après le dernier caractère saisi.
  • Débitage permet à l’action d’avoir lieu au plus une fois par fenêtre de temps. Il convient aux scrolles, aux changements de taille et aux appuis répétés sur « sauter en avant » : le traitement se poursuit, mais pas à chaque événement.
  • Annulation arrête le traitement qui a déjà commencé.
  • En d’autres termes, le débouncing et le débitage décident quand un nouveau traitement peut commencer, tandis que l’annulation détermine si le traitement déjà lancé peut se poursuivre.

    Une boîte de recherche qui semble réactive utilise généralement l’un ou l’autre de ces mécanismes. Le débouncing empêche l’envoi d’une requête à chaque frappe, et l’annulation garantit qu’une requête déjà envoyée ne puisse pas revenir plus tard pour écraser des résultats plus récents. L’article dédié sur les conditions de concurrence que le débouncing ne peut pas résoudre dans les interfaces de recherche analyse en profondeur ce cas.

    Décortiquer une course dans une playlist

    Pensez à ce qui se passe dans un lecteur audio lorsque quelqu’un parcourt une playlist plus rapidement que le réseau ne peut le gérer.

    L’utilisateur clique sur la piste A. L’application demande des métadonnées, éventuellement une URL signée pour le fichier, l’artwork et la forme d’onde, et le son commence à être mis en mémoire tampon. Avant même que tout cela ne soit terminé, l’utilisateur clique sur la piste B, puis sur C.

    Sans annulation, tous les éléments liés à la piste A continuent d’arriver :

    • Les métadonnées de A arrivent et définissent le titre.
  • L’audio de A est attaché à l’élément, ce qui produit une brève séquence du mauvais morceau.
  • La durée affichée passe de celle de A à celle de B, puis à celle de C.
  • Au niveau mobile, deux fichiers complets sont téléchargés que personne ne jouera jamais.
  • Les outils d’analyse peuvent enregistrer une lecture de A car sa demande a abouti, même si le morceau n’a jamais été écouté.
  • Si l’utilisateur quitte l’application pendant le chargement, les mises à jour de l’état ciblent toujours un lecteur qui n’est plus actif.
  • Avec l’annulation, en touchant B, tout ce qui a été déclenché au nom de A est interrompu. Seules les réponses autorisées à modifier l’élément audio, le titre et la forme d’onde appartiennent au morceau sélectionné à ce moment-là. En sautant à nouveau, le traitement de B est annulé tandis que celui de C se poursuit. Lorsque le lecteur est fermé, des opérations de nettoyage sont exécutées, et rien ne reste pour écrire dans un composant qui a disparu.

    Partagez un seul contrôleur pour chaque demande de sélection, et un unique abort() met fin à l’ensemble du groupe.

    L’effet global est que l’interface utilisateur ne reflète jamais que l’intention actuelle de l’utilisateur.

    Le même schéma dans les applications plus grandes

    Les grandes applications appliquent cette idée partout :

    • Typeahead et filtres. Une nouvelle requête annule la demande précédente, ce qui correspond à une concurrence entre la liste de lecture et les résultats de recherche plutôt qu’avec des chansons.
    • Routing côté client. Lorsqu’un utilisateur quitte une page avant que ses données ne soient renvoyées, des frameworks et des bibliothèques de données tels que Next.js, Remix, TanStack Query et SWR peuvent annuler le travail en attente lié à la navigation ; au fond, il s’agit toujours d’un signal d’annulation. Consultez la documentation de chaque bibliothèque pour savoir exactement quand elle annule et si elle transmet un signal à votre outil de récupération de données.
  • Panneaux de contrôle. Changer la plage de dates peut déclencher un nouveau chargement dans dix widgets. Si vous la changez à nouveau deux secondes plus tard sans interrompre le premier chargement, l’écran peut afficher des chiffres provenant de deux plages différentes.
  • Tout ce qui dispose d’un bouton Annuler. Les téléchargements, les exportations ainsi que l’option « arrêter la génération » dans une interface de chat appellent toutes la fonction controller.abort() derrière ce bouton.
  • Points clés

    • Le débouncing et le throttling contrôlent le moment où le travail commence ; seule l’annulation détermine si le travail déjà commencé est terminé.
    • Créez un AbortController par unité d’intention, transmettez son signal partout où le travail est exécuté, et remplacez-le plutôt que de le réutiliser.
    • Traitez AbortError comme une sortie normale et relancez les autres erreurs.
    • Dans React, annulez les opérations dans la fonction de nettoyage d’effect afin que toute modification des champs ou démontage annule automatiquement les requêtes en attente.
    • Rappelez-vous ce à quoi un signal ne parvient pas, comme le chargement proprement dit d’un élément multimédia, et nettoyez ces éléments explicitement.

    L’annulation ne rendra pas une interface plus intelligente en soi, mais elle garantit que les requêtes d’hier ne peuvent pas entrer en conflit avec celles d’aujourd’hui. Une fois que vous avez vu ce genre de conflit se manifester à l’écran ou via un haut-parleur, il devient une bonne habitude d’associer un signal à toute requête autorisée à mettre à jour l’interface utilisateur.