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 :
- Réponse rapide : le spinner apparaît et disparaît en une poignée de millisecondes. L’œil perçoit un clignotement, un effet de flicker : la page « saute », l’interface tremble pour rien.
- Réponse lente : le composant de chargement reste affiché pendant une durée indéterminée, sans que l’utilisateur sache si l’application travaille encore ou si elle est figée.
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 :
- Un délai avant affichage : le spinner ne se montre que si l’opération dépasse une certaine durée. On considère généralement qu’en deçà de 200 ms, l’interaction est perçue comme instantanée et il est inutile, voire contre-productif, d’afficher quoi que ce soit. C’est ce qui élimine le flicker.
- Une durée minimale d’affichage : une fois le spinner montré, il reste visible un temps plancher, quoi qu’il arrive. Cela évite l’autre forme de clignotement, lorsqu’une réponse arrive juste après le déclenchement du spinner.
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.

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é.

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 :
- Chargement instantané, par construction : toute interaction lit et écrit en local, sans aller-retour réseau dans le chemin critique.
- Fonctionnement hors ligne : l’application reste 100 % utilisable même sans connexion, la synchronisation se faisant dès que le réseau revient.
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.