← Retour au blog
Architecture Diagramme d'un optimistic update : un clic fait passer le compteur de 0 à 1 immédiatement, pendant que la requête réseau reste en attente en arrière-plan.

Les états de chargement mal gérés qui détruisent l'UX de vos apps

Flicker, spinners interminables : comment garantir des loading states cohérents avec spin-delay, optimistic updates et local-first pour une UX fluide et prévisible.

📅 ✍️ Antoine Coulon
loading-stateuxoptimistic-updatelocal-firstfrontend

Dans une application, presque chaque action déclenche une requête qui part sur le réseau. Le réflexe est connu : on affiche un état de chargement dès que l’action démarre, puis on l’actualise une fois la réponse reçue. Le problème, c’est que la durée de cette réponse est fondamentalement inconnue et non déterministe. Et de ce détail anodin naît l’un des défauts d’expérience les plus répandus du web : un chargement qui clignote, ou qui s’éternise.

Le vrai problème : une durée de réponse imprévisible

Quand on branche bêtement un spinner sur l’état « requête en cours », on s’expose à deux mauvais scénarios symétriques, dictés par la latence réelle de l’appel :

Le résultat est une UX instable et imprévisible : le même bouton, cliqué deux fois de suite, peut se comporter de deux façons radicalement différentes selon l’humeur du réseau. La vraie question devient alors : comment gérer correctement un chargement dont, par définition, on ne connaît pas la durée à l’avance ?

Rendre le chargement cohérent avec spin-delay

La première stratégie ne cherche pas à supprimer le chargement, mais à le rendre cohérent. L’idée est d’encadrer l’affichage du spinner par deux seuils temporels, plutôt que de le coller au cycle de vie brut de la requête. Une bibliothèque comme spin-delay implémente exactement ce comportement à partir de deux paramètres :

Concrètement, côté React, cela revient à dériver un booléen « faut-il montrer le spinner ? » à partir du booléen brut « la requête est-elle en cours ? » :

import { useSpinDelay } from "spin-delay";

function SaveButton({ isSaving }: { isSaving: boolean }) {
  // Le spinner ne s'affiche que si l'opération dépasse `delay` (200 ms),
  // et reste alors visible au moins `minDuration` (500 ms) pour éviter
  // tout clignotement, quelle que soit la latence réelle du réseau.
  const showSpinner = useSpinDelay(isSaving, {
    delay: 200,
    minDuration: 500,
  });

  return (
    <button disabled={isSaving}>
      {showSpinner ? <Spinner /> : "Enregistrer"}
    </button>
  );
}

Grâce à cette approche, on offre une expérience stable et prévisible : les opérations vraiment rapides ne déclenchent aucun spinner, et celles qui en déclenchent un l’affichent suffisamment longtemps pour rester lisibles. Le comportement est cohérent quelle que soit la durée de chargement, c’est précisément ce qu’on cherchait.

Faire disparaître le chargement : l’optimistic update

spin-delay rend le chargement supportable. Mais on peut aller plus loin et chercher à le faire disparaître de la perception de l’utilisateur. C’est l’objet de l’optimistic update : mettre à jour l’interface immédiatement, en partant de l’hypothèse assumée que l’action côté serveur va réussir.

Optimistic update : au clic, le compteur passe instantanément de 0 à 1 par une mise à jour immédiate de l&#x27;état local, tandis que la requête réseau reste en attente en arrière-plan.

L’UI n’est alors plus du tout liée à un état de chargement : elle reflète tout de suite le résultat attendu, avec zéro latence perçue. La requête réseau, elle, continue de partir en arrière-plan. Avec les API modernes de React, le motif s’exprime très directement :

import { useOptimistic } from "react";

function LikeButton({
  likes,
  like,
}: {
  likes: number;
  like: () => Promise<void>;
}) {
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    likes,
    (current) => current + 1
  );

  async function handleClick() {
    addOptimisticLike(); // l'UI se met à jour immédiatement, 0 latence
    await like();        // la requête réseau suit en arrière-plan
  }

  return <button onClick={handleClick}>J'aime · {optimisticLikes}</button>;
}

Le bénéfice est évident (chargement instantané, expérience fluide), mais il a une contrepartie qu’il faut assumer. En cas d’erreur côté serveur, l’optimisme se retourne contre vous : l’UI affichait un résultat qui ne s’est jamais produit. Il faut alors corriger la mise à jour pour refléter la réalité (revert / rollback) et donner un feedback clair à l’utilisateur, sous peine d’un comportement profondément contre-intuitif. L’optimistic update n’est donc pertinent que pour des actions à fort taux de succès et au coût d’échec faible : un like, un changement de statut, l’ajout d’un élément à une liste.

Aller plus loin : l’approche local-first

On peut voir le local-first comme une généralisation de l’optimistic update, érigée en architecture plutôt qu’en astuce ponctuelle. Le principe : on enregistre d’abord les données en local, dans le navigateur (donc zéro latence) puis on les synchronise en arrière-plan avec le serveur via un sync engine dédié.

Architecture local-first : les changements sont appliqués immédiatement dans un Local Store côté navigateur, puis un Sync Engine se charge de les synchroniser en arrière-plan avec la base de données distante.

Le renversement est important. Là où l’optimistic update reste un patch appliqué à un état d’UI particulier, le local-first fait du stockage local la source de vérité immédiate de l’application. Les conséquences sont à la hauteur :

Le prix à payer se situe au moment de la synchronisation. En cas de conflit ou d’erreur, il faut réconcilier a posteriori l’ensemble des données avec lesquelles l’utilisateur a interagi, un problème de fond bien plus exigeant qu’un simple rollback localisé. C’est un choix d’architecture, pas une simple option d’affichage : il engage le modèle de données, la stratégie de synchronisation et la gestion des conflits.

Dans les deux cas, optimistic update comme local-first, la perception est instantanée, et l’expérience devient plus fluide et plus rapide, même si, derrière, les interactions avec le serveur et leur durée réelle restent rigoureusement inchangées. On n’a pas accéléré le réseau ; on a cessé de faire attendre l’utilisateur dessus.

Conclusion

Les états de chargement sont un détail que l’on traite trop souvent par réflexe, et qui finit par saboter l’expérience d’une application entière. La règle tient en une phrase : si vous ne pouvez pas éviter un état de chargement, rendez-le au moins cohérent : c’est ce que permet spin-delay, en encadrant le spinner par un délai d’apparition et une durée minimale d’affichage.

Et si vous le pouvez, évitez-le carrément en changeant de stratégie d’UX, voire d’architecture : l’optimistic update masque la latence pour les actions à fort taux de succès, et le local-first va jusqu’à faire du navigateur la source de vérité immédiate. Trois niveaux d’ambition, un même objectif : une interface qui répond, plutôt qu’une interface qui fait patienter.