Accueil / Articles

Interface utilisateur optimiste sans désynchronisation : captures d’écran, annulations et demandes interrompues

Apprenez à créer des mises à jour optimistes qui restent correctes : enregistrez l’état actuel, mettez à jour instantanément, revenez en arrière en cas d’échec, annulez les requêtes obsolètes, et sachez quand ne pas utiliser ce modèle.

Éditeur : REACTAPP.TOP10 min de lecture2290 mots

Sources: Frontend Design Patterns: Optimistic Updates

Avec une connexion rapide, le temps de trajet aller-retour derrière un bouton « aimer » est imperceptible. Dans un train naviguant entre un réseau LTE faible et un 3G à peine utilisable, ce même temps de trajet peut prendre cinq ou dix secondes, et pendant cette période le bouton reste inactif tandis que l’utilisateur se demande si la pression a bien été enregistrée. Les mises à jour optimistes comblent cet écart en modifiant d’abord l’interface avant de solliciter le serveur. Ce guide montre comment mettre en œuvre ce schéma en trois étapes claires, comment le protéger contre les requêtes concurrentes, et comment déterminer où il convient d’utiliser cette approche et où elle représente plutôt un inconvénient.

Le problème caché derrière une simple pression

Imaginez un post ayant reçu 999 likes, accompagné d’un cœur entouré d’un cadre. Dans sa mise en œuvre la plus stricte, appuyer sur le cœur envoie une demande à un serveur qui peut se trouver sur un autre continent. Le serveur authentifie l’utilisateur, enregistre les données dans une base de données et répond ; ce n’est qu’alors que le cœur devient rouge et que le compteur passe à 1 000. Un délai de quelques centaines de millisecondes est acceptable, mais cinq secondes sont insupportables. Les utilisateurs qui ne voient aucune réponse appuient à nouveau, puis une troisième fois, avant d’abandonner l’application ; c’est pourquoi ce retard constitue un moyen très efficace de perdre des utilisateurs.

Cet appui déclenche également plus de traitements que le simple changement d’état d’une variable booléenne. Une plateforme typique effectue trois actions en arrière-plan :

  • Elle appelle une extrémité API qui incrémente le compteur des likes pour ce post.
  • Elle ajoute le post à une collection sauvegardée sur le compte de l’utilisateur afin qu’il puisse être retrouvé ultérieurement.
  • Celui-ci envoie un signal au système de recommandations, la partie du backend chargée de choisir ce qui sera affiché ensuite, afin que des contenus similaires apparaissent plus fréquemment.
  • Tous les trois dépendent du réseau. Cependant, sur le frontend, il suffit de modifier deux éléments d’état : savoir si le cœur est rempli et le nombre à côté. Si l’interface attend tous les effets du côté serveur avant de modifier ces valeurs, l’application semble défectueuse même lorsqu’elle fonctionne correctement.

    L’optimisme en tant que mensonge délibéré et réparable

    Une mise à jour optimiste résout ce problème en faisant modifier immédiatement par le frontend son propre état, en partant du principe que la requête qu’il vient d’envoyer aboutira. Dès que le doigt quitte l’écran, le cœur devient rouge et le compteur affiche 1 000, même si rien n’a encore atteint le serveur. Cet petit mensonge est ce qui donne l’impression d’une interaction instantanée.

    Cela n’est acceptable que s’il existe un plan en cas d’échec de l’hypothèse. Le train pourrait entrer dans un tunnel et interrompre la connexion, ou le serveur pourrait renvoyer une erreur. Laisser l’écran afficher 1 000 tandis que la base de données indique encore 999 crée un désynchronisme : l’interface et la source de vérité ne sont pas en accord. L’interface utilisateur doit alors revenir à l’état réel, c’est-à-dire un cœur entouré et le chiffre 999, et informer généralement l’utilisateur par un message temporaire qui apparaît brièvement avant de disparaître, comme "Impossible d’enregistrer cela. Essayez à nouveau."

    Mettre en œuvre ce modèle en trois étapes

    Changer une variable dans l’espoir que la requête fonctionne relève du hasard, pas d’une conception. Une mise à jour optimiste fiable sépare trois responsabilités : capturer l’état actuel, afficher le résultat attendu, puis concilier cela avec la réponse du serveur.

    Étape 1 : capturer un instantané

    Au moment où rien à l’écran ne change, enregistrez les valeurs que l’utilisateur peut voir actuellement, comme le fait que la publication soit aimée et son compteur actuel. Le gestionnaire reçoit la publication, un outil de mise à jour d’état et une fonction de notification, puis importe l’appel API qu’il fera ultérieurement :

    // likeButton.js
    import { updateLikeOnServer } from "./api.js";
    
    async function handleLikeClick(post, setPost, showToast) {
      const previousState = { liked: post.liked, likeCount: post.likeCount };
      // act two goes here
      // act three goes here
    }
    

    Après cette étape, vous disposez d’un seul objet inchangé qui décrit précisément ce qui apparaît à l’écran, prêt à être restauré en cas de problème. Il est important de copier ces deux champs dans un nouvel objet : si vous conserviez une référence à un objet mutable qui change par la suite, le « snapshot » changerait avec lui.

    Étape 2 : appliquer le résultat attendu

    Ensuite, mettez à jour l’interface utilisateur directement, avant même que l’appel réseau ne commence :

    async function handleLikeClick(post, setPost, showToast) {
      const previousState = { liked: post.liked, likeCount: post.likeCount };
    
      setPost({ liked: true, likeCount: post.likeCount + 1 });
      // act three goes here
    }
    

    L’écran affiche maintenant ce que l’utilisateur a demandé : un cœur rouge et un compteur plus élevé, tandis que le serveur ne sait encore rien. Un détail à surveiller dans votre propre code : cet exemple transmet un objet entièrement nouveau ne contenant que liked et likeCount. Si votre méthode de mise à jour remplace l’ensemble du poste au lieu de fusionner les champs, étendez la valeur existante au nouveau contenu afin que des propriétés comme id ne soient pas perdues.

    Étape 3 : réconcilier avec le serveur

    Finalement, envoyez la vraie requête et conservez l’état optimiste ou annulez-le. La requête est placée dans un bloc try ; si elle génère une erreur, le gestionnaire restaure l’état initial et affiche un message d’erreur.

    async function handleLikeClick(post, setPost, showToast) {
      const previousState = { liked: post.liked, likeCount: post.likeCount };
      setPost({ liked: true, likeCount: post.likeCount + 1 });
      try {
        await updateLikeOnServer(post.id);
      } catch (error) {
        setPost(previousState);
        showToast("Couldn't save that. Try again.");
      }
    }
    

    L’aide API utilise fetch du navigateur. Notez que fetch ne rejette les requêtes que en cas de défaillance réseau, et non en raison des codes d’erreur HTTP ; c’est pourquoi l’aide vérifie response.ok avant de lancer explicitement une exception. Sans cette vérification, une réponse 500 serait considérée comme un succès et le retrait en arrière ne s’exécuterait jamais :

    // api.js
    export async function updateLikeOnServer(postId) {
      const response = await fetch(`/api/posts/${postId}/like`, {
        method: "POST",
      });
    
      if (!response.ok) {
        throw new Error("Like request failed");
      }
      return response.json();
    }
    

    Après la troisième étape, il n’y a que deux issues possibles : soit la requête a réussi et l’état optimiste est devenu vrai, soit elle a échoué et l’interface revient à l’état enregistré à la première étape. Dans les deux cas, l’interface ne montre jamais rien que le backend ne peut pas gérer pendant plus longtemps que le temps nécessaire pour que la requête échoue.

    Où les mises à jour optimistes sont utiles

    Cette structure supplémentaire en vaut la peine uniquement lorsque le retrait en arrière est peu coûteux et que la vitesse perçue est très importante. Trois catégories correspondent constamment à ce profil :

    1. Interactions sociales. Les likes, les abonnements et les réactions impliquent peu de risque. Si l’une des nombreuses tentatives par jour échoue, le coût est que l’utilisateur doit essayer à nouveau.
    2. Messagerie. Dans des applications de chat comme WhatsApp ou Slack, un message envoyé sur une connexion médiocre apparaît immédiatement dans la conversation, souvent avec un effet de transparence ou un petit icône d’horloge grise. Il s’agit d’une mise à jour optimiste affichant un indicateur de traitement en attente : le message est visible à l’écran avant même d’avoir atteint le serveur.
    3. Tableaux de type glisser-déposer. Glisser une carte dans la colonne « Terminé » d’un tableau Kanban comme Jira ou Trello serait comparable à glisser dans du sable humide si la carte devait attendre une confirmation. Dans le meilleur des cas, elle se place immédiatement à sa destination ; en cas d’échec de la demande, elle revient dans sa colonne initiale.

    Où l’optimisme devient dangereux

    Imaginons maintenant une application bancaire. Un locataire appuie sur « Envoyer » pour transférer cinq mille dollars de loyer à son propriétaire. Avec une mise à jour optimiste, l’écran indique immédiatement que le transfert a réussi et que le solde est désormais nul, si bien que l’utilisateur ferme l’application en pensant que le loyer a été payé.

    Dans l’ombre, la demande échoue ; que ce soit en raison de fonds insuffisants ou d’une transaction rejetée, cela n’a pas d’importance. L’utilisateur continue de croire que l’argent a été transféré jusqu’à ce que le propriétaire appelle pour demander le loyer. Au minimum, cela entraîne une demande d’assistance et une perte de confiance, et cela se produit parce qu’un modèle conçu pour des actions bon marché et réversibles a été appliqué à une situation où se tromper a des conséquences réelles.

    La même prudence s’applique à quelques catégories apparentées :

    1. Transactions financières. Afficher un indicateur de chargement. L’attente indique que le système a effectivement vérifié l’opération avant de la confirmer.
    2. Suppressions irréversibles. Lorsque quelqu’un supprime son compte, l’écran ne doit pas se vider avant que le serveur n’ait confirmé que les données ont disparu.
  • Changements de sécurité. Les mises à jour des mots de passe et les paramètres à deux facteurs exigent une certitude, et non une interface affichant à l’avance un succès prétendu.
  • Le critère décisif n’est pas l’importance perçue d’une action, mais le coût lié à une erreur concernant cette action. Faire attendre quelqu’un trois secondes de plus pour voir un indicateur en rotation est bien préférable à lui annoncer que de l’argent a été transféré alors que ce n’est pas le cas.

    Lorsque deux tapotements se succèdent

    Il existe un second mode de défaillance qui ne se produit que lorsque l’utilisateur agit à nouveau avant que la première demande ne soit terminée. Sur ce train lent, l’utilisateur aime la publication, la faisant passer de 999 à 1 000, puis la déteste immédiatement, la ramenant à 999. Supposons que l’action d’aimer nécessite cinq secondes pour s’exécuter tandis que l’action de détester n’en nécessite que deux ; dans ce cas, l’action de détester peut arriver en premier.

    Le serveur traite ensuite le « unlike » pour une publication qu’il ne considère pas encore comme aimée, donc rien ne se passe, et quelques secondes plus tard il traite le « like ». Le compte finit par être marqué comme aimé sur le serveur, tandis que l’écran affiche le contraire de ce que l’utilisateur a fait en dernier. Lorsque l’état final est déterminé par l’ordre d’arrivée plutôt que par l’ordre des actions de l’utilisateur, on a une condition de course.

    Annulation des requêtes obsolètes avec AbortController

    Le remède habituel est l’annulation : lorsque une nouvelle requête pour le même ressource débute, on demande au navigateur d’abandonner l’ancienne. Le AbortController intégré peut annuler une requête fetch déjà en cours. L’exemple conserve une référence au contrôleur actif, l’annule avant de lancer une nouvelle requête, et choisit POST ou DELETE en fonction de l’état souhaité :

    // likeButton.js
    let activeRequest = null;
    
    async function toggleLike(postId, shouldLike) {
      if (activeRequest) {
        activeRequest.abort();
      }
      const controller = new AbortController();
      activeRequest = controller;
      await fetch(`/api/posts/${postId}/like`, {
        method: shouldLike ? "POST" : "DELETE",
        signal: controller.signal,
      });
    }
    

    Chaque nouvelle pression annule la requête précédente, de sorte que seule l’action la plus récente doit aboutir. Il convient de connaître deux points importants avant de compter uniquement sur ce mécanisme :

    • L’annulation empêche le navigateur d’attendre la réponse ; elle ne garantit pas que le serveur n’ait jamais reçu ou traité la requête. Pour des garanties plus solides, concevez l’endpoint en fonction de l’état final souhaité (aimé ou non aimé) afin que les requêtes répétées ou ordonnées différemment convergent, ou ajoutez un numéro de séquence que le serveur peut utiliser pour ignorer les écritures obsolètes.
    • Une requête fetch annulée génère une erreur AbortError. Si vous combinez cela avec le retrait de l’état enregistré de la troisième étape, vérifiez cette erreur et sautez le retrait, sinon annuler une requête obsolète effacerait à tort l’état optimiste plus récent.

    Dans les applications React, des bibliothèques de données gèrent une grande partie de ces mécanismes internes. TanStack Query, qui gère le cache des états serveur et les cycles de vie des requêtes, ainsi que Redux Toolkit, l’ensemble d’outils officiel pour écrire de la logique Redux, offrent tous deux un support intégré pour les mises à jour optimistes et l’annulation, ce qui vous permet de rarement avoir à implémenter manuellement le AbortController. Consultez leur documentation pour savoir précisément ce qui est annulé dans chaque cas ; il reste utile de comprendre le mécanisme sous-jacent. Pour en savoir plus sur les erreurs pouvant survenir avec le hook intégré à React, consultez les cinq modes d’échec des mises à jour optimistes.

    Points clés

    • Un aller-retour réseau peut être suffisamment lent pour faire croire qu’une application fonctionnelle est défectueuse ; les mises à jour optimistes affichent donc d’abord le résultat attendu avant de le confirmer par la suite.
  • Structurez chaque mise à jour optimiste en état instantané, application puis réconciliation ; c’est l’état instantané qui rend le retrait simplifié.
  • Rendez les échecs visibles : fetch ne rejette pas en cas d’erreurs HTTP, donc lancez une exception si !response.ok, et informez l’utilisateur avec un message pop-up lors du retrait.
  • Utilisez ce modèle pour des actions peu coûteuses et réversibles telles que les « like », les messages et les déplacements sur le tableau ; affichez un indicateur de chargement pour les paiements, les suppressions et les modifications de sécurité.
  • Prévenez les conflits en annulant les requêtes remplacées, en ignorant AbortError dans la logique de retrait, et en privilégiant des points d’entrée qui définissent un état final plutôt que de le commuter.
  • La vitesse est une fonctionnalité que le réseau ne vous offrira pas gratuitement. Une interface optimiste accepte qu’elle ne peut pas contrôler la connexion, répond néanmoins avec confiance à l’utilisateur et reste prête à revenir sur cette réponse dès qu’elle se révèle fausse.

    Lectures associées