Async JavaScript - 10 - AbortController, annuler proprement

AbortController et AbortSignal pour annuler des fetch, des listeners, et tes propres opérations async.

10 - AbortController, annuler proprement

Ce que tu vas apprendre

  • Comment fonctionne le duo AbortController / AbortSignal
  • Annuler des fetch, des event listeners, et des opérations custom
  • AbortSignal.timeout() et AbortSignal.any()
  • Le pattern de cleanup dans React useEffect

Prerequisites

Avoir lu l'article 09 - Les combinateurs de Promises.


Pendant longtemps, JavaScript n'avait aucun mecanisme standard pour annuler une opération asynchrone. Tu lancais un fetch et tu attendais. Si l'utilisateur changeait de page, le fetch continuait en arriere-plan, la réponse arrivait, le callback essayait de mettre à jour un composant demonte, et React t'affichait un warning desagreable. AbortController a change ca.

Le principe

AbortController est un objet avec deux propriétés :

  • signal : un AbortSignal que tu passes aux opérations que tu veux pouvoir annuler
  • abort() : une méthode qui déclenché l'annulation
typescriptconst controller = new AbortController();

// Passe le signal a fetch
fetch("/api/data", { signal: controller.signal })
  .then((r) => r.json())
  .then((data) => console.log(data))
  .catch((error) => {
    if (error.name === "AbortError") {
      console.log("Fetch annule");
    } else {
      throw error;
    }
  });

// Plus tard, annule le fetch
controller.abort();

Quand tu appelles abort(), toutes les opérations liees au signal sont annulees. Le fetch rejette avec une erreur de type AbortError. C'est une erreur "normale" -- l'utilisateur a demande l'annulation, pas besoin de la logger comme un bug.

AbortSignal.timeout()

Plutot que de bricoler un timeout avec Promise.race (comme dans l'article 09), tu peux utiliser AbortSignal.timeout() :

typescript// Annule automatiquement apres 5 secondes
const response = await fetch("/api/slow", {
  signal: AbortSignal.timeout(5000),
});

C'est plus propre que le pattern maison, et ca nettoie le timer automatiquement si le fetch se termine avant le timeout. Pas de fuite mémoire.

La différence avec le pattern Promise.race : ici, le fetch est vraiment annule au niveau réseau. Avec race, le fetch continuait en arriere-plan.

AbortSignal.any()

Parfois tu veux annuler quand n'importe laquelle de plusieurs conditions est remplie. AbortSignal.any() combine plusieurs signaux :

typescriptconst userController = new AbortController();

// Annule si l'utilisateur clique "Annuler" OU si ca prend plus de 10s
const signal = AbortSignal.any([
  userController.signal,
  AbortSignal.timeout(10_000),
]);

const response = await fetch("/api/heavy-report", { signal });

// L'utilisateur clique "Annuler"
cancelButton.addEventListener("click", () => userController.abort());

Un seul signal qui reagit a deux sources d'annulation. Avant AbortSignal.any(), il fallait écoûter manuellement chaque signal et propager l'abort.

Annuler des event listeners

AbortController ne sert pas qu'a fetch. Tu peux l'utiliser pour nettoyer des event listeners :

typescriptconst controller = new AbortController();

document.addEventListener("mousemove", handleMouseMove, {
  signal: controller.signal,
});
document.addEventListener("keydown", handleKeyDown, {
  signal: controller.signal,
});
window.addEventListener("resize", handleResize, {
  signal: controller.signal,
});

// Un seul appel pour tout nettoyer
controller.abort();
// Equivalent a trois removeEventListener, sans garder les references aux handlers

C'est tellement plus propre que de stocker chaque handler dans une variable pour pouvoir appeler removeEventListener plus tard. Un seul controller, un seul abort(), tout est nettoye.

Opérations async custom annulables

Tu peux rendre tes propres fonctions annulables en acceptant un signal :

typescriptasync function pollStatus(
  url: string,
  intervalMs: number,
  signal?: AbortSignal
): Promise<string> {
  while (true) {
    signal?.throwIfAborted(); // lance AbortError si deja annule

    const response = await fetch(url, { signal });
    const data = await response.json();

    if (data.status === "complete") {
      return data.result;
    }

    // Attente annulable
    await new Promise<void>((resolve, reject) => {
      const timer = setTimeout(resolve, intervalMs);
      signal?.addEventListener("abort", () => {
        clearTimeout(timer);
        reject(signal.reason);
      }, { once: true });
    });
  }
}

// Usage
const controller = new AbortController();
const result = await pollStatus("/api/job/123", 2000, controller.signal);

// Annuler le polling
controller.abort();

Le pattern clé : signal.throwIfAborted() au début de chaque itération, et le signal passe a fetch pour annuler aussi la requête en cours. L'attente entre les polls est elle aussi annulable grace au listener sur le signal.

React useEffect et AbortController

Le cas d'usage le plus frequent en frontend. Sur paltemps.fr, chaque composant qui fetch des donnees suit ce pattern :

typescriptfunction PlaceDetail({ placeId }: { placeId: string }) {
  const [place, setPlace] = useState<Place | null>(null);
  const [error, setError] = useState<string | null>(null);

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

    async function loadPlace() {
      try {
        const response = await fetch(`/api/places/${placeId}`, {
          signal: controller.signal,
        });
        const data = await response.json();
        setPlace(data);
      } catch (err) {
        if (err instanceof Error && err.name === "AbortError") {
          return; // composant demonte, on ignore
        }
        setError("Impossible de charger le lieu");
      }
    }

    loadPlace();

    return () => controller.abort(); // cleanup
  }, [placeId]);

  // ...
}

Quand placeId change, React exécuté le cleanup de l'effet précédent (qui appelle abort()), puis lance le nouvel effet. Le fetch de l'ancien ID est annule, pas de race condition, pas de warning "setState on unmounted component".

Verifier si un signal est deja annule

typescriptasync function doWork(signal?: AbortSignal) {
  // Methode 1 : verifier la propriete
  if (signal?.aborted) {
    throw new Error("Already aborted");
  }

  // Methode 2 : lancer directement (ES2022)
  signal?.throwIfAborted();

  // Methode 3 : ecouter l'evenement
  signal?.addEventListener("abort", () => {
    cleanup();
  });
}

throwIfAborted() est la méthode la plus concise. Elle lance signal.reason (par défaut un DOMException de type AbortError).

L'erreur classique : réutiliser un controller

typescriptconst controller = new AbortController();
controller.abort();

// Ce fetch est annule IMMEDIATEMENT
await fetch("/api/data", { signal: controller.signal });
// AbortError !

Un AbortController est a usage unique. Une fois abort() appele, le signal reste dans l'état "aborted" indefiniment. Cree un nouveau controller pour chaque opération ou groupe d'opérations.

Résumé

  • AbortController fournit un mecanisme standard d'annulation
  • AbortSignal.timeout() remplace le pattern Promise.race pour les timeouts
  • AbortSignal.any() combine plusieurs conditions d'annulation
  • Passe le signal a fetch, addEventListener, et tes fonctions custom
  • Dans React, créé un controller dans useEffect et appelle abort() dans le cleanup
  • Un controller est a usage unique, n'essaie pas de le réutiliser

Article précédent : 09 - Les combinateurs de Promises Article suivant : 11 - Race conditions en JavaScript

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.