Aller au contenu principal

Un asynchrone robuste

Double clic, réponses dans le désordre, données écrasées : provoquer les bugs classiques, puis les corriger.

Objectifs de la séance​

  • empêcher le double envoi d'une action ;
  • éviter la mise à jour perdue quand deux tâches modifient la même donnée ;
  • ignorer une réponse périmée qui arrive après une réponse plus récente ;
  • annuler une requête devenue inutile avec AbortController ;
  • représenter l'état d'une requête sans contradiction (inactif, chargement, succes, erreur).

Petite vérification​

Avant de commencer, vérifiez si vous savez...

  1. exécuter async.js et lire la dernière ligne affichée
  2. dire ce que renvoie une fonction async qui se termine par return 5
  3. dire quelle propriété de la réponse d'un fetch il faut tester avant de lire le JSON
📌 Réponse attendue

Notions théoriques​

Pourquoi un code « qui marche » peut quand même bugger​

Un programme asynchrone ne s'exécute pas toujours dans l'ordre où on l'a écrit : plusieurs tâches se chevauchent. Un code qui fonctionne lors d'un test tranquille peut produire des résultats faux quand l'utilisateur clique deux fois, tape vite, ou quand le réseau est lent. Ces bugs sont rares, aléatoires, donc très difficiles à reproduire. La seule vraie protection est de les prévoir.

Voici les quatre situations à connaître. Tous les exemples utilisent cette fonction fournie :

function attendre(millisecondes) {
return new Promise((resolve) => setTimeout(resolve, millisecondes));
}

Situation 1 : le double clic​

Un utilisateur impatient clique deux fois sur « Envoyer ». Sans protection, la réponse est envoyée deux fois (deux scores enregistrés, deux paiements...).

La protection : un indicateur enCours, testé et activé avant le premier await.

let enCours = false;

async function envoyerReponse() {
if (enCours) {
console.log("Envoi déjà en cours, clic ignoré");
return;
}
enCours = true; // juste après le test, SANS aucun await entre les deux

try {
await attendre(300); // l'envoi réel
console.log("Réponse envoyée");
} finally {
enCours = false; // toujours remis à false, même en cas d'erreur
}
}

Le test et l'activation se font sans await entre eux : JavaScript n'exécute qu'une chose à la fois, donc personne ne peut s'intercaler. Le finally garantit que le bouton n'est jamais bloqué définitivement.

Situation 2 : la mise à jour perdue​

Deux tâches modifient la même donnée. Chacune lit la valeur, attend, puis écrit sa version : la seconde écrase le travail de la première.

let score = 0;

// FRAGILE : on lit AVANT d'attendre
async function ajouterPointsFragile(points) {
const actuel = score; // lecture
await attendre(10); // pendant ce temps, l'autre tâche modifie score
score = actuel + points; // écrit une valeur périmée
}

// (dans une fonction async)
await Promise.all([ajouterPointsFragile(10), ajouterPointsFragile(10)]);
console.log(score); // 10 au lieu de 20 : un ajout a été perdu !

La correction : lire la valeur au moment d'écrire, après l'attente.

// SÛR : on lit APRÈS avoir attendu
async function ajouterPoints(points) {
await attendre(10);
score = score + points; // lecture et écriture dans la même instruction
}
Le même principe dans React et en base de données
  • React : setScore((ancien) => ancien + 10) reçoit toujours la dernière valeur (au lieu de setScore(score + 10) qui utilise une photo périmée).
  • Base de données : un score ne se calcule pas côté application puis s'écrit ; le serveur doit faire le calcul (voir la sécurité dans la série Expo).

Situation 3 : les réponses dans le désordre​

L'utilisateur tape « mot », puis « mots ». Deux recherches partent. Le réseau est capricieux : la première (« mot ») répond après la seconde. Résultat : l'écran affiche la réponse de « mot », alors que l'utilisateur a tapé « mots ». C'est une réponse périmée.

function rechercher(texte, delai) {
return attendre(delai).then(() => `Résultats pour "${texte}"`);
}

let affichage = "";

async function lancerRecherche(texte, delai) {
affichage = await rechercher(texte, delai); // BUG : la dernière réponse reçue gagne
}

lancerRecherche("mot", 300); // répond après 300 ms
lancerRecherche("mots", 100); // répond après 100 ms (plus vite)
// après 400 ms : affichage vaut "Résultats pour "mot"" (périmé !)

Correction 1 : un numéro de requête. Chaque requête reçoit un numéro. À l'arrivée de la réponse, on vérifie que c'est toujours la plus récente.

let dernierNumero = 0;

async function lancerRechercheSure(texte, delai) {
dernierNumero += 1;
const monNumero = dernierNumero; // mon numéro à moi

const resultat = await rechercher(texte, delai);

if (monNumero !== dernierNumero) {
return; // une recherche plus récente existe : cette réponse est périmée, on l'ignore
}
affichage = resultat;
}

Situation 4 : annuler une requête devenue inutile​

Ignorer la réponse périmée est bien, mais la requête a quand même consommé du réseau. Mieux vaut l'annuler. Pour cela, on utilise un AbortController : il fournit un signal que l'on transmet à la requête, et une méthode abort() pour l'annuler.

Avec fetch, c'est direct :

const controleur = new AbortController();
fetch("https://exemple.fr/api/recherche?q=mots", { signal: controleur.signal });

controleur.abort(); // annule la requête : la promesse est rompue avec une erreur "AbortError"

Pour nos exercices, voici une recherche annulable (à copier telle quelle, sans chercher à en comprendre chaque détail) :

function rechercherAnnulable(texte, delai, signal) {
return new Promise((resolve, reject) => {
const minuterie = setTimeout(() => resolve(`Résultats pour "${texte}"`), delai);
signal.addEventListener("abort", () => {
clearTimeout(minuterie);
reject(new DOMException("Recherche annulée", "AbortError"));
});
});
}

Et la façon de l'utiliser : annuler la précédente, lancer la nouvelle, distinguer une annulation volontaire d'une vraie erreur.

let controleur = null;

async function lancerRechercheAnnulable(texte, delai) {
controleur?.abort(); // on annule la recherche précédente (s'il y en a une)
const monControleur = new AbortController();
controleur = monControleur;

try {
affichage = await rechercherAnnulable(texte, delai, monControleur.signal);
} catch (erreur) {
if (erreur instanceof Error && erreur.name === "AbortError") {
return; // annulation volontaire : ce n'est pas un problème
}
throw erreur; // une vraie erreur : on la laisse remonter
}
}

C'est exactement le schéma utilisé dans un useEffect de React (partie 7) et dans la série Expo.

Bonus : l'état d'une requête, sans contradiction​

Quand on gère un chargement avec plusieurs booléens (enChargement, erreur, donnees), on peut se retrouver dans un état impossible (chargement et erreur en même temps). Il est plus sûr de n'avoir qu'une seule variable qui prend une valeur parmi un petit nombre de choix :

// statut : "inactif" | "chargement" | "succes" | "erreur"
let etat = { statut: "inactif" };

async function charger() {
etat = { statut: "chargement" };
try {
const donnees = await attendre(200).then(() => ["question 1", "question 2"]);
etat = { statut: "succes", donnees };
} catch {
etat = { statut: "erreur", message: "Chargement impossible" };
}
}

À chaque instant, l'application est dans un seul état : l'écran affiche un cercle (chargement), un message d'erreur (erreur) ou la liste (succès), jamais deux à la fois.

Bonne pratique - La checklist de l'asynchrone

Pour chaque action asynchrone, posez-vous quatre questions :

  1. Que se passe-t-il si l'utilisateur la déclenche deux fois ?
  2. Que se passe-t-il si une réponse arrive en retard ?
  3. Que se passe-t-il si elle échoue ?
  4. Que se passe-t-il si deux tâches modifient la même donnée ?

Test de mémorisation/compréhension​


Où faut-il placer if (enCours) return; enCours = true; pour bien empêcher le double envoi ?


Pourquoi le score vaut-il 10 et non 20 dans l'exemple de la mise à jour perdue ?


Quelle ligne d'une fonction asynchrone évite d'utiliser une réponse périmée ?


Que fait controleur.abort() sur un fetch qui utilise controleur.signal ?


Pourquoi tester erreur.name === "AbortError" dans le catch ?


Quel est l'avantage d'une seule variable statut ("inactif", "chargement", "succes", "erreur") ?



TP pour réfléchir et résoudre des problèmes​

Votre mission : rendre robustes quatre fonctions asynchrones fragiles. Travaillez dans le fichier labo-js/robuste.js : placez en tête attendre, rechercher et rechercherAnnulable (copiés de la partie « Notions théoriques »), puis ajoutez chaque étape. Chaque scénario est lancé depuis une fonction principal() (écrite à la fin).

Étape 1 — Bloquer le double envoi​

Un joueur double-clique sur « Valider ». Seul un envoi doit partir.


Bonne pratique - Garde en tête, finally en queue

Test et activation de enCours avant tout await, remise à false dans un finally : aucune ouverture à un deuxième envoi, aucun risque de blocage définitif.

Étape 2 — Éviter la mise à jour perdue​

Deux tâches ajoutent 10 points. Le score final doit valoir 20. Complétez la version sûre.


Bonne pratique - Ne jamais garder une copie d'une donnée partagée à travers un await

Si une valeur partagée peut changer pendant l'attente, relisez-la après l'attente, au moment même de l'utiliser.

Étape 3 — Ignorer une réponse périmée​

Deux recherches partent : « mot » (lente) puis « mots » (rapide). L'affichage final doit être celui de « mots ».


Bonne pratique - Vérifier après chaque await

Après une attente, le monde a pu changer. Avant d'utiliser le résultat, vérifiez qu'il est toujours d'actualité.

Étape 4 — Annuler la recherche précédente​

Reprenez le schéma d'annulation : la nouvelle recherche annule l'ancienne. Une annulation ne doit pas être signalée comme une erreur.


Bonne pratique - Relancer les vraies erreurs

throw erreur à la fin du catch relance toute erreur qui n'est pas une annulation. Ainsi, une vraie panne n'est jamais avalée par erreur.

📌 Une solution

Ce qu'il faut retenir​

NotionRésumé
Double envoiIndicateur enCours testé et activé avant le premier await, remis à false dans un finally.
Mise à jour perdueNe pas lire une donnée partagée avant un await pour l'écrire après : relire au moment d'écrire.
Réponse périméeNuméroter les requêtes et ignorer toute réponse qui n'est plus la plus récente.
AnnulationAbortController : abort() annule, AbortError n'est pas une vraie erreur, les autres sont relancées.
État d'une requêteUne seule variable statut (inactif, chargement, succes, erreur) évite les états contradictoires.
RéflexeSe demander : deux fois ? en retard ? en échec ? même donnée modifiée par deux tâches ?

Aperçu de la prochaine séance​

Vous maîtrisez les bases du JavaScript : bravo ! Dans la partie 6, vous découvrez TypeScript, qui ajoute à tout ce que vous savez un correcteur automatique : il vérifie vos données et vos fonctions avant même l'exécution, et détecte une grande partie des erreurs vues depuis le début du cours.