Attendre le serveur avant de changer l’écran, c’est honnête, mais lent : un like qui apparaît au bout de 400 ms donne l’impression d’être cassé. Une mise à jour optimiste change l’interface tout de suite, suppose que la requête réussira et annule le changement si ce n’est pas le cas.
L’idée tient en une phrase. C’est dans les détails que les applications cassent : ce que vous restaurez exactement en cas d’échec, ce qui se passe quand deux réponses arrivent dans le mauvais ordre, et comment le serveur évite d’appliquer deux fois la même action quand vous réessayez.
Le schéma : mémoriser, appliquer, confirmer ou annuler
Trois étapes. Mémoriser la valeur actuelle, la changer localement, envoyer la requête. En cas de succès, la réponse du serveur remplace votre supposition — elle peut différer (un id, un horodatage, un texte nettoyé). En cas d’échec, restaurez ce que vous aviez mémorisé et dites-le à l’utilisateur : une valeur qui revient en arrière sans message ressemble à un bug.
import { Service, inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { TodoApi, type Todo } from './todo-api';
@Service()
export class TodoStore {
private readonly api = inject(TodoApi);
readonly todos = signal<readonly Todo[]>([]);
readonly error = signal<string | null>(null);
async toggle(id: string): Promise<void> {
const before = this.todos().find((t) => t.id === id);
if (!before) return;
this.patch(id, { done: !before.done }); // 1. l’écran change MAINTENANT
try {
const saved = await firstValueFrom(this.api.update(id, { done: !before.done }));
this.patch(id, saved); // 2. la version du serveur l’emporte (horodatages, texte normalisé...)
} catch {
this.patch(id, { done: before.done }); // 3. annuler UNIQUEMENT cet élément...
this.error.set(`Impossible d’enregistrer « ${before.title} ». Annulé.`); // ...et le dire
}
}
private patch(id: string, change: Partial<Todo>): void {
this.todos.update((list) => list.map((t) => (t.id === id ? { ...t, ...change } : t)));
}
}L’interface
Enregistré sur le serveur
- Rédiger les notes de version
- Relire la pull request
- Mettre à jour les dépendances
- Corriger le test instable
- effet visible après
- —
- requêtes
- 0
- échouées
- 0
- interface ≠ serveur
- 0
Annulez ce que vous avez changé, pas toute la liste
Enregistrer un snapshot de toute la liste est la version la plus tentante, et elle est fausse dès que deux requêtes sont en vol. Le snapshot pris par la première requête ne contient pas la seconde modification ; quand la première échoue, le restaurer efface aussi une modification que le serveur a déjà enregistrée. Le scénario de la démo fait exactement cela : l’interface et le serveur finissent par ne plus être d’accord, sans rien pour les réconcilier. Annulez par élément, ou appliquez l’opération inverse.
// ✗ Un snapshot de TOUTE la liste capture aussi les autres modifications encore en vol
async toggle(id: string) {
const snapshot = this.todos(); // pris avant le prochain clic de l’utilisateur...
this.patch(id, { done: !this.find(id).done });
try {
await firstValueFrom(this.api.update(id, ...));
} catch {
this.todos.set(snapshot); // ...donc ceci annule aussi une modification que le serveur a DÉJÀ enregistrée
}
}La création optimiste demande une étape de plus. Le nouvel élément n’a pas encore de vrai id, donc on lui en donne un temporaire, remplacé quand le serveur répond. D’ici là, les modifications de cet élément doivent attendre ou utiliser l’id temporaire, que le serveur ne connaît pas.
async add(title: string): Promise<void> {
const temp: Todo = { id: `tmp-${crypto.randomUUID()}`, title, done: false };
this.todos.update((list) => [...list, temp]);
try {
const saved = await firstValueFrom(this.api.create({ title }));
// l’id temporaire disparaît : toute modification ultérieure utilise le vrai
this.todos.update((list) => list.map((t) => (t.id === temp.id ? saved : t)));
} catch {
this.todos.update((list) => list.filter((t) => t.id !== temp.id));
this.error.set(`« ${title} » n’a pas été ajouté.`);
}
}Des réponses qui arrivent dans le mauvais ordre
Activez un réglage, désactivez-le, réactivez-le, rapidement. Trois requêtes partent, et rien ne garantit l’ordre dans lequel les réponses reviennent. Si chaque réponse écrase l’état, c’est la dernière réponse arrivée qui gagne — pas le dernier choix de l’utilisateur.
Chaque clic envoie une requête. Sa barre s’arrête quand la réponse arrive.
- Votre choix
- off
- La dernière réponse gagne
- off …
- Seulement la plus récente
- off …
« La dernière réponse gagne » s’est trompée 0 fois sur 0.
Deux remèdes, pour deux problèmes différents. Seulement la plus récente : chaque requête reçoit un numéro et seule la réponse à la plus récente peut écrire — cela répare l’interface. Mais le serveur peut aussi avoir reçu les requêtes dans le désordre. Si l’état final sur le serveur compte, sérialisez les écritures avec concatMap (une requête à la fois, dans l’ordre des clics), ou envoyez une version que le serveur vérifie. Pour les recherches, où seule la dernière compte, switchMap annule les anciennes ; voir les opérateurs d’ordre supérieur.
private latest = 0;
readonly enabled = signal(false); // ce que voit l’utilisateur (optimiste)
private readonly confirmed = signal(false); // ce que le serveur a confirmé en dernier
async setNotifications(on: boolean): Promise<void> {
const id = ++this.latest;
this.enabled.set(on);
try {
const saved = await firstValueFrom(this.api.saveSettings({ notifications: on }));
this.confirmed.set(saved.notifications);
// une réponse plus ancienne qui arrive tard ne doit pas écraser un choix plus récent
if (id === this.latest) this.enabled.set(saved.notifications);
} catch {
if (id === this.latest) this.enabled.set(this.confirmed());
}
}Idempotence : une action, appliquée une seule fois
Les interfaces optimistes réessaient, et voici le piège : la requête est arrivée au serveur, la commande a été créée, seule la réponse s’est perdue. Un nouvel essai crée une seconde commande. Le remède est une Idempotency-Key : un UUID généré une seule fois par action de l’utilisateur et envoyé à chaque nouvel essai. Le serveur retient les clés déjà traitées et, pour une répétition, renvoie le résultat d’origine au lieu de refaire le travail.
import { HttpClient, HttpContext, HttpContextToken, HttpInterceptorFn } from '@angular/common/http';
import { retry } from 'rxjs';
export const IDEMPOTENCY_KEY = new HttpContextToken<string | null>(() => null);
/** La clé voyage dans le contexte de la requête et devient un en-tête. */
export const idempotencyInterceptor: HttpInterceptorFn = (req, next) => {
const key = req.context.get(IDEMPOTENCY_KEY);
return next(key ? req.clone({ setHeaders: { 'Idempotency-Key': key } }) : req);
};
// Une clé par ACTION DE L’UTILISATEUR, réutilisée à chaque nouvel essai de cette action.
placeOrder(order: Order) {
const key = crypto.randomUUID();
return this.http
.post<OrderConfirmation>('/api/orders', order, {
context: new HttpContext().set(IDEMPOTENCY_KEY, key),
})
.pipe(retry({ count: 3, delay: 1000 })); // même observable → même contexte → même clé
}La clé appartient à l’action de l’utilisateur, pas à la requête HTTP : générez-la quand l’utilisateur appuie sur le bouton, pas dans un intercepteur qui en créerait une nouvelle à chaque essai. Les nouveaux essais eux-mêmes sont traités dans HttpClient et intercepteurs.
Quand OUI, quand NON
- Des actions fréquentes et peu coûteuses, qui réussissent presque toujours : likes, cases cochées, marquer comme lu.
- Des modifications faciles à annuler : réordonner, renommer, la quantité dans un panier.
- Des interfaces où la latence se sent à chaque clic : listes, tableaux, éditeurs.
- Les paiements et tout ce qui touche à l’argent : l’utilisateur a besoin d’une vraie confirmation.
- Les actions irréversibles : envoyer un e-mail, supprimer définitivement, publier.
- Une validation qui échoue souvent côté serveur (un nom d’utilisateur déjà pris) : vous passeriez votre temps à annuler.
Pièges fréquents
- Un rollback silencieux. La coche disparaît sans message ; l’utilisateur croit l’application cassée, ou ne remarque rien et passe à autre chose.
- Un snapshot de toute la liste. Il annule aussi d’autres modifications en vol, y compris certaines que le serveur a déjà enregistrées.
- La dernière réponse gagne. Une vieille réponse arrivée en retard écrase le choix le plus récent. Numérotez les requêtes.
- Réessayer sans clé d’idempotence. Une réponse perdue devient une commande en double.
- Ne jamais se réconcilier avec le serveur. Des ids temporaires jamais remplacés, un texte normalisé qui ne revient jamais : l’interface s’éloigne peu à peu des données.
Aide-mémoire
mise à jour optimiste snapshot → appliquer tout de suite → envoyer → version serveur | rollback + message
rollback seulement ce que cette requête a changé (l’élément / l’opération inverse)
pas toute la liste un snapshot de la liste annule aussi les autres requêtes en vol
réponse du serveur remplace la supposition locale (ids, horodatages, texte normalisé)
ids temporaires tmp-uuid jusqu’à la réponse de « create », puis le vrai id
réponses désordonnées un id par requête : seule la plus récente peut écrire (newest-only)
écritures dans l’ordre concatMap : une à la fois, dans l’ordre des clics
lectures switchMap : annuler l’ancienne recherche, garder la nouvelle
nouveaux essais Idempotency-Key = un UUID par action, le même à chaque essai
jamais optimiste paiements, actions irréversibles, validation qui échoue souvent
rollback sans message = une UI qui a l’air cassée