Accueil / Articles / Six erreurs de synchronisation asynchrone qui se manifestent comme des bugs de rendu React

Six erreurs de synchronisation asynchrone qui se manifestent comme des bugs de rendu React

Apprenez à repérer six schémas asynchrones en JavaScript, allant des valeurs capturées obsolètes aux retours de Promise manquants, qui font en sorte que les composants React affichent un état incorrect ou impossible.

1485 mots

De nombreux problèmes classés comme des « bugs React » n’ont jamais leur origine dans React. Une liste affichant les résultats d’une requête ancienne, un indicateur de chargement qui ne disparaît jamais, une notification de succès qui s’affiche avant que les données soient prêtes : React n’est simplement que le lieu où ces problèmes deviennent visibles. La véritable cause se trouve généralement à un niveau inférieur, dans la manière dont le JavaScript asynchrone transmet les valeurs dans le temps.

Le code asynchrone introduit le concept de temps dans un programme, et chaque await ou .then() représente un point où les choses peuvent changer en arrière-plan. Ci-dessous sont présentés six erreurs fréquentes liées au timing, les raisons pour lesquelles chacune provoque un symptôme UI confus, ainsi que la petite modification nécessaire pour les corriger, afin de pouvoir les détecter lors des revues de code.

Erreur 1 : Des requêtes obsolètes continuent d’écrire dans l’état actuel

Une boîte de recherche en est un exemple typique. L’effet ci-dessous délaisse déjà les saisies de 300 ms et efface le compteur à la fermeture.

useEffect(() => {
  if (!query) {
    setResults([]);
    return;
  }

  const timeoutId = setTimeout(async () => {
    const results = await search(query);
    setResults(results);
  }, 300);

  return () => {
    clearTimeout(timeoutId);
  };
}, [query]);

Imaginez maintenant quelqu’un qui tape « Async JavaScript Mistakes ». Il tape « Async », attend juste assez longtemps pour que le mécanisme de débouncing prenne fin, et une requête est envoyée. Ensuite il continue de taper, ce qui déclenche une deuxième requête pour l’ensemble de la phrase. Le débouncing a réduit le nombre de requêtes, mais il n’a pas empêché deux d’entre elles d’être en cours en même temps.

L’ordre dans lequel les requêtes quittent le navigateur est sous votre contrôle. L’ordre dans lequel les réponses reviennent ne l’est pas. Si la requête plus longue est traitée en premier et que la requête « Async » l’est en second, la deuxième appel à setResults l’emporte, et la liste affiche les résultats pour « Async » alors que l’entrée indique clairement « Async JavaScript Mistakes ».

Rien de problématique ici. Les réseaux sont autorisés à réordonner les réponses, et le code n’a jamais indiqué à React quelle réponse restait pertinente. La solution habituelle consiste à annuler les tâches obsolètes à l’aide d’un AbortController créé au sein de l’effet et annulé lors de sa nettoyage, de sorte qu’une réponse obsolète n’arrive jamais ou est ignorée. Si le flux de données repose déjà sur RxJS, switchMap offre la même sémantique « seul le dernier résultat compte ». Pour une analyse plus approfondie de ce scénario précis, consultez comment résoudre les conditions de concurrence que le débouncing ne peut pas résoudre.

Erreur 2 : Prendre une décision en se basant sur des valeurs capturées avant un await

Tenez chaque await pour une frontière. Ce que vous saviez avant constitue un instantané ; tout ce qui se produit après s’exécute à un moment ultérieur, éventuellement après que d’autres états aient changé.

const handlePublish = async () => {
  const { canPublish } = permissions;

  await saveDraft();

  if (canPublish) {
    publish();
  }
};

Cet gestionnaire lit canPublish à partir de permissions, attend que le brouillon soit enregistré, puis décide s’il faut le publier. Le problème est que cette décision se base sur une valeur qui était vraie au début. Si les permissions de l’utilisateur ont été révoquées pendant l’exécution de saveDraft(), la constante locale indique toujours « oui ».

Le même problème se pose avec les éléments sélectionnés, les filtres actifs, les paramètres de route, le contenu de l’éditeur et de nombreux autres éléments d’état. Lorsqu’une décision après un await dépend de l’état actuel de l’application, relisez cet état après la frontière (à partir d’une référence, d’un stockage ou d’une nouvelle requête) plutôt que de vous fier à la copie antérieure.

Erreur 3 : Présumer que le reste de la fonction s’exécute toujours

Voici un indicateur de chargement associé à une requête fetch.

setLoading(true);

const dashboard = await getDashboard();

setDashboard(dashboard);
setLoading(false);

Si getDashboard() échoue, l’exécution quitte la fonction au niveau du await, et setLoading(false) n’est jamais exécuté. L’indicateur de chargement reste affiché indéfiniment.

Lorsqu’un élément d’état modélise la durée de vie d’une opération asynchrone, son réinitialisation doit avoir lieu tant en cas de succès que d’échec. Un bloc finally exprime directement cette intention :

setLoading(true);

try {
  const dashboard = await getDashboard();
  setDashboard(dashboard);
} finally {
  setLoading(false);
}

Remarquez que le bloc try ne contient toujours pas de catch. L’erreur continue d’être propagée à celui qui a appelé ce code, ce qui est souvent souhaitable, tandis que l’indicateur de chargement est garanti pour être effacé. Ajoutez un catch uniquement si c’est le bon endroit pour transformer l’échec en élément d’interface, comme un message d’erreur.

Erreur 4 : Des requêtes indépendantes générant des états d’écran incohérents

Démarrer des requêtes non liées en parallèle est souvent tout à fait raisonnable.

getProfile().then(setProfile);
getPermissions().then(setPermissions);
getPreferences().then(setPreferences);

Chaque réponse se retrouve dans son propre état dès qu’elle arrive. Cela signifie que React peut afficher n’importe quelle combinaison : un profil sans permissions ni préférences, des permissions et des préférences sans profil, etc., selon n’importe quel ordre déterminé par le réseau.

Certaines de ces combinaisons peuvent être dénuées de sens ou même dangereuses pour votre écran. Lorsque plusieurs réponses décrittent ensemble un état d’écran cohérent, les requêtes peuvent rester en parallèle tandis qu’un seul responsable assemble le résultat, par exemple en attendant Promise.all et en enregistrant tout dans une seule mise à jour d’état, ou en modélisant l’écran comme un réducteur avec des états de chargement, prêt et erreur explicites. Le téléchargement en parallèle et l’état indépendant de l’interface utilisateur sont deux décisions distinctes.

Erreur 5 : Construire l’état suivant à partir d’une capture obsolète

Cette erreur se cache facilement dans les gestionnaires d’événements.

const handleAdd = async () => {
  await saveItem(newItem);

  setItems([...items, newItem]);
};

items correspond à ce que le composant contenait au moment où handleAdd a commencé. Pendant que saveItem() était en attente, une autre action a pu ajouter ou supprimer des éléments. Lorsque le gestionnaire reprend son exécution, il utilise l’ancien tableau et efface ces modifications plus récentes.

Lorsque la valeur suivante dépend de la précédente, laissez React fournir cette dernière via le formulaire d’actualisation :

setItems(current => [...current, newItem]);

L’actualiseur fonctionne sur l’état le plus récent enregistré au moment où la mise à jour est traitée, ce qui permet de conserver les modifications simultanées. Les closures obsolètes ne représentent pas seulement un problème lié aux effets : n’importe quel callback asynchrone peut retenir des valeurs plus longtemps que prévu.

Erreur 6 : Briser une chaîne de promesses en ne renvoyant rien

Cette chaîne semble strictement séquentielle : enregistrer, puis mettre à jour les widgets, puis marquer comme enregistré.

saveDashboard()
  .then(() => refreshWidgets())
  .then(() => setSaved(true));

Examinez maintenant comment refreshWidgets pourrait être écrit :

const refreshWidgets = () => {
  getWidgets().then(setWidgets);
};

La fonction lance une requête mais renvoie undefined, et non la Promise. Du point de vue de la chaîne externe, refreshWidgets() s’achève instantanément, ce qui fait que le suivant .then s’exécute immédiatement et que setSaved(true) peut être appelé alors que les widgets sont encore en chargement.

La solution consiste à utiliser un seul mot-clé pour renvoyer la Promise afin que la chaîne puisse attendre son résultat :

const refreshWidgets = () => {
  return getWidgets().then(setWidgets);
};

Une fonction async atteint le même résultat de manière implicite, car elle renvoie toujours une Promise qui se résout lorsque son corps est terminé :

const refreshWidgets = async () => {
  const widgets = await getWidgets();
  setWidgets(widgets);
};

Dans l’interface utilisateur, ce bug peut ressembler à un message de succès qui apparaît prématurément, à des données obsolètes restant affichées, ou à un redirigement qui a lieu avant que le renouvellement ne soit effectué. Aucun de ces cas ne indique clairement l’absence d’une instruction return. Les règles de linting de TypeScript, telles que @typescript-eslint/no-floating-promises, peuvent détecter automatiquement de nombreux cas de ce type.

Points clés

La frontière entre React et JavaScript n’est pas toujours évidente. React affiche l’état, mais c’est le JavaScript asynchrone qui détermine quand cet état arrive et s’il reste à jour, obsolète ou incohérent au moment où il arrive. Lors de l’examen du code asynchrone dans les composants, trois questions permettent d’identifier la plupart de ces bugs :

  • Quand cette valeur a-t-elle été capturée, et a-t-elle pu changer entre deux appels à await ?
  • Quelle opération asynchrone est responsable de cette mise à jour d’état, et une opération antérieure peut-elle écraser une opération plus récente ?
  • L’opération reste-t-elle pertinente une fois terminée, et chaque scénario, y compris les échecs, laisse-t-il l’interface utilisateur dans un état valide ?