reactanimations

Optimistic UI en React : une interface qui ment consciemment

Optimistic UI en React : une interface qui ment consciemment

Dans l'article sur les micro-interactions, j'écrivais à propos du seuil de Doherty – si un système répond en moins de 400 ms, l'utilisateur le perçoit comme instantané. Les micro-interactions comblent cet écart en animant l'état d'un bouton avant que la réponse du serveur ne revienne. L'Optimistic UI va un cran plus loin : il n'anime pas seulement l'attente, il suppose d'emblée que l'action va réussir, et affiche immédiatement le résultat final – le cœur dans le bouton « J'aime » se remplit instantanément, le compteur augmente de un, alors que la requête vers le serveur vient tout juste de démarrer. L'interface dit à l'utilisateur quelque chose qui n'est pas encore vrai – en supposant que ça le deviendra sous peu.

Cela fonctionne parfaitement tant que l'hypothèse est juste – et en pratique, pour des actions du type « aimer », « ajouter aux favoris » ou « marquer comme lu », elle l'est dans l'écrasante majorité des cas. Le problème commence là où la plupart des implémentations essaient de faire cela à la main : un setState au clic, un catch avec un setState inverse en cas d'erreur. Ça a l'air anodin dans un test isolé avec un seul clic. Ça déraille dès que l'utilisateur clique deux fois de suite rapidement sur le bouton – exactement le même quatrième élément du modèle de Saffer (boucles et modes) de l'article sur les micro-interactions, sauf que cette fois l'erreur n'est pas cosmétique, elle mène à un état réellement incohérent.

Pourquoi un rollback manuel déraille avec deux clics

Imaginez l'implémentation manuelle la plus évidente : le clic bascule immédiatement liked dans l'état local, envoie une requête, et dans le catch, ramène liked à la valeur d'avant le clic, mémorisée dans une variable avant l'envoi de la requête.

jsx

// Rollback naïf – fonctionne pour un clic, se casse avec deux
const handleClick = async () => {
const previousLiked = liked; // instantané figé de l'état AVANT le clic
setLiked(!liked);
try {
await toggleLike(postId, !liked);
} catch {
setLiked(previousLiked); // revient à l'état d'avant CE clic
}
};

Le scénario qui casse tout : l'utilisateur clique sur « aimer » (démarrage de la requête A, previousLiked = false), puis 100 ms plus tard clique sur « ne plus aimer » (démarrage de la requête B, previousLiked = true, car l'état local a déjà changé en true). Si la requête A échoue alors que la requête B est en cours ou a déjà réussi, le catch de la requête A ramènera l'état à false – écrasant ainsi l'effet du clic B, que l'utilisateur a effectué consciemment et qui a pu déjà réussir. Le rollback d'une requête a annulé le résultat de l'autre, car les deux opéraient sur la même variable d'état partagée, sans aucune connaissance l'une de l'autre. C'est exactement le type de bug qui ne se révèle pas dans un test manuel rapide – il se révèle en production, quand un utilisateur réel clique plus vite que le développeur ne l'avait imaginé en écrivant le code.

Comment useOptimistic évite ce problème structurellement, pas par un correctif

useOptimistic en React n'est pas un enrobage plus pratique d'un setState plus try/catch. C'est un modèle conceptuel différent : au lieu de conserver un unique champ d'état mutable, que vous basculez et annulez manuellement, useOptimistic calcule une surcouche temporaire à la volée, à partir de l'état réel actuel, visible uniquement le temps que dure l'opération en cours (transition). Une fois l'opération terminée – avec succès ou en échec – la surcouche disparaît d'elle-même, révélant ce qui, à cet instant, constitue le véritable état. Vous n'écrivez pas de code de rollback. Le rollback est une conséquence naturelle de la disparition de la surcouche temporaire, pas un chemin de code distinct que vous pourriez oublier de gérer, ou mal gérer.

jsx

"use client";
import { useOptimistic, useTransition } from "react";
import { toggleLikeAction } from "./actions";
const LikeButton = ({ postId, liked, likeCount }) => {
const [isPending, startTransition] = useTransition();
const [optimistic, setOptimistic] = useOptimistic(
{ liked, likeCount },
(state, nextLiked) => ({
liked: nextLiked,
likeCount: state.likeCount + (nextLiked ? 1 : -1),
}),
);
const handleClick = () => {
const nextLiked = !optimistic.liked;
startTransition(async () => {
setOptimistic(nextLiked);
await toggleLikeAction(postId, nextLiked);
// Pas de rollback manuel dans un catch – si l'action lève une erreur,
// React rejette de toute façon la surcouche à la fin de la transition,
// révélant le vrai "liked" reçu dans les props.
});
};
return (
<button
type="button"
onClick={handleClick}
aria-pressed={optimistic.liked}
className={isPending ? "like-button--pending" : ""}
>
<span aria-hidden="true">{optimistic.liked ? "♥" : "♡"}</span>
{optimistic.likeCount}
</button>
);
};
export default LikeButton;

La différence clé par rapport à la version manuelle : la surcouche calculée par useOptimistic se base sur le state transmis en premier argument – c'est-à-dire l'état réel, actuel provenant des props (généralement issu d'une Server Action et de revalidatePath, comme dans l'article sur les Server Actions) – et non sur un instantané séparé, figé manuellement dans une variable locale. Quand l'utilisateur clique deux fois de suite, chaque appel à setOptimistic calcule une nouvelle surcouche par rapport à l'état actuel, donc le second clic ne risque pas d'être écrasé par un rollback tardif du premier – il n'existe pas de chemin séparé « revenir à la valeur mémorisée » qui pourrait se tromper sur l'instant précis auquel elle correspond.

Quand l'optimistic UI est une bonne idée, et quand c'est un mensonge dangereux

Toutes les actions ne se prêtent pas à ce motif, et ce n'est pas une question de goût, mais de conséquences en cas d'hypothèse erronée. « Aimer », « ajouter aux favoris », « marquer comme lu » – peu coûteuses en cas d'échec, facilement réversibles, réussissent presque toujours. Un paiement, la suppression irréversible d'un compte, l'envoi d'un message à une autre personne – ici, afficher optimistiquement un succès avant qu'il ne se soit réellement produit est risqué : l'utilisateur prend d'autres décisions sur la base d'une information qui pourrait s'avérer fausse, et annuler un tel « mensonge » quelques secondes plus tard est bien plus déstabilisant qu'un simple spinner qui dure juste un peu plus longtemps.

Même là où l'optimistic UI a du sens, un rollback silencieux sans aucune information reproduit exactement le problème perceptif qu'il était censé résoudre. L'utilisateur a vu le cœur se remplir, a considéré l'affaire close, a détourné son attention ailleurs – et un instant plus tard, le cœur revient silencieusement à son état vide, sans explication. C'est la même erreur de « l'absence de boucles et de modes » du modèle de Saffer, simplement décalée dans le temps : il y avait un retour pour le succès, mais plus aucun pour l'échec. La solution est le même outil que celui décrit dans l'article sur l'accessibilité – aria-live="polite" ou role="alert" sur le message d'échec, pour que l'annulation de l'état ne soit silencieuse ni visuellement ni sonorement pour quiconque ne regarde pas précisément ce bouton au moment où il revient en arrière.

Pièges et bonnes pratiques

  • setOptimistic doit être appelé à l'intérieur d'une transition. En dehors de startTransition (ou d'une action transmise à useActionState/<form action>), useOptimistic ne fonctionnera pas comme attendu – le mécanisme est indissociable du cycle de vie d'une transition précise, pas d'un moment quelconque du rendu.
  • La fonction de mise à jour transmise à useOptimistic doit être pure et peu coûteuse. Elle s'exécute de façon synchrone pendant le rendu pour calculer la surcouche – des calculs complexes ou des effets de bord à cet endroit sont une erreur de catégorie, pas seulement une question de performance.
  • Ne cachez pas un échec en silence. Un rollback silencieux sans message est pire que l'absence totale de mise à jour optimiste – l'utilisateur reçoit une fausse confirmation, puis aucune information indiquant que quelque chose s'est finalement mal passé.
  • Réservez ce motif aux actions peu coûteuses et réversibles. Là où le coût d'une hypothèse erronée est élevé (paiements, opérations irréversibles), un simple état de chargement avec une confirmation claire après coup reste le meilleur choix, plutôt qu'une interface qui devine par avance.

Conclusion

L'Optimistic UI n'est pas une astuce d'animation – c'est l'hypothèse consciente qu'une action va réussir, affichée à l'utilisateur avant même que le serveur ne l'ait confirmée. Une implémentation manuelle par setState et catch fonctionne jusqu'à ce que quelqu'un clique plus vite que le développeur ne l'avait prévu – le rollback d'une opération peut alors écraser le résultat d'une autre, car les deux partagent le même instantané d'état figé. useOptimistic élimine cette classe d'erreurs structurellement : la surcouche est toujours calculée par rapport à l'état réel actuel, et non par rapport à une valeur mémorisée séparément, donc le rollback n'est pas du code que vous écrivez et pourriez mal écrire – c'est la conséquence naturelle de la disparition d'une hypothèse temporaire. Il ne reste qu'une décision qu'aucune API ne prendra à votre place : cette action est-elle suffisamment peu coûteuse et réversible pour qu'il soit seulement judicieux de mentir à son sujet, ne serait-ce qu'un instant.