~10 min de lecture
Optimistic update avec les signals : ton UI répond au clic, pas au réseau
Clique sur le bouton favori d'une app que tu utilises tous les jours. Twitter, Spotify, peu importe. Le cœur se remplit instantanément. Pourtant la requête HTTP derrière met le même temps que celle de ton app : quelques centaines de millisecondes sur un réseau correct, bien plus en 4G dans un train.
La différence, c'est que ces apps ne font pas attendre l'utilisateur pour une opération qui réussit
dans l'immense majorité des cas. Elles mettent l'UI à jour d'abord, et confirment ensuite. C'est le
pattern optimistic update, et avec les signals il tient en quelques lignes (les extraits utilisent
signal et computed, stables depuis Angular 17, et input.required(), stable depuis Angular 19).
Le problème, c'est que la version en quelques lignes a un bug sournois que tu ne verras jamais en dev, et qui se déclenche précisément chez l'utilisateur pressé qui clique plusieurs fois de suite. On va construire la version naïve, la casser, puis écrire celle qui tient.
Le code que tout le monde écrit
Un bouton favori classique : l'état vit dans un signal, le clic envoie la requête, et on attend la réponse pour mettre à jour.
import { ChangeDetectionStrategy, Component, inject, input, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Component({
selector: 'app-favorite-button',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<button type="button" [disabled]="pending()" (click)="toggle()">
{{ isFavorite() ? 'Remove from favorites' : 'Add to favorites' }}
</button>
`,
})
export class FavoriteButton {
private readonly http = inject(HttpClient);
readonly productId = input.required<string>();
protected readonly isFavorite = signal(false);
protected readonly pending = signal(false);
protected toggle(): void {
const target = !this.isFavorite();
this.pending.set(true);
this.http
.put<void>(`/api/favorites/${this.productId()}`, { favorite: target })
.subscribe({
next: () => {
this.isFavorite.set(target);
this.pending.set(false);
},
error: () => this.pending.set(false),
});
}
}
Ce code est correct. Il est aussi désagréable à utiliser, et le [disabled] le trahit : si tu as
besoin de verrouiller le bouton pendant la requête, c'est que ton UI vit au rythme du réseau.
Voilà ce que vit l'utilisateur à chaque clic :
- Il clique. Rien ne se passe, à part le bouton qui devient grisé.
- Le temps de l'aller-retour HTTP, l'UI reste sur l'ancien libellé, inerte.
- La réponse arrive, le libellé change enfin.
Avec 80 ms d'aller-retour en fibre, personne ne s'en plaint. Sur une connexion mobile réelle, chaque clic coûte une demi-seconde de latence perçue. Tu sais déjà ce que le serveur va répondre : 204. Tu attends juste pour l'afficher.
Inverser l'ordre : écrire d'abord, confirmer ensuite
L'idée de l'optimistic update est simple : l'état local change au moment du clic, la requête part en arrière-plan, et le cas d'erreur (le cas rare) restaure l'état précédent.
private readonly toasts = inject(ToastService);
protected toggle(): void {
const previous = this.isFavorite();
this.isFavorite.set(!previous);
this.http
.put<void>(`/api/favorites/${this.productId()}`, { favorite: !previous })
.subscribe({
error: () => {
this.isFavorite.set(previous);
this.toasts.show('Could not update favorites, please retry.', 'error');
},
});
}
Plus de pending, plus de disabled. Le signal fait exactement ce pour quoi il est conçu :
l'écriture synchrone marque la vue à rafraîchir, OnPush compris, et le libellé change au prochain
cycle de détection de changements, sans attendre le réseau.
Deux détails qui ne sont pas du style :
- La requête envoie la valeur cible, pas un ordre de bascule. Un
PUT { favorite: true }est idempotent : le rejouer ne change rien. Un endpointPOST /togglequi inverse côté serveur rend le pattern imprévisible dès que quelque chose se passe mal : un échec partiel sur deux clics laisse le serveur inversé sans que le client puisse le savoir, un retry d'une requête déjà appliquée inverse une fois de trop, et un état client périmé (un autre onglet est passé par là) fait basculer la valeur côté serveur, pas l'intention de l'utilisateur. Si ton API ne propose que le toggle relatif, négocie l'endpoint absolu avant de faire de l'optimistic. - L'erreur doit se voir. Le contrat de l'optimistic update, c'est "je te crois sur parole, et je te préviens si je me suis trompé". Un toast suffit (on en a construit un avec des signals ici), mais il faut quelque chose.
Et c'est là que la plupart des articles s'arrêtent, pile avant le bug.
Le piège : le rollback qui restaure un état périmé
Scénario : le réseau rame, mais ton bouton répond au clic, donc l'utilisateur hésite à sa guise. "Ajouter... non... en fait si." Trois clics.
- Clic 1 :
previous = false, l'UI passe àtrue, la requête A part avecfavorite: true. - Clic 2 :
previous = true, l'UI passe àfalse, la requête B part avecfavorite: false. - Clic 3 :
previous = false, l'UI passe àtrue, la requête C part avecfavorite: true. Dernière intention de l'utilisateur : favori. - B puis C réussissent. A, elle, traîne et finit en timeout. Son callback d'erreur restaure son
snapshot :
isFavorite.set(false).
Résultat : le serveur dit favori (C a réussi), l'UI affiche le contraire, et un toast s'excuse d'une erreur que l'utilisateur ne peut pas relier à quoi que ce soit. Le snapshot du clic 1 ne vaut que si rien n'a bougé depuis ; deux clics plus tard, le restaurer écrase l'intention la plus récente.
Et le simple double clic ? Sur un toggle, il retombe sur ses pieds tant qu'au plus une des deux
requêtes échoue : le snapshot du clic 1 (l'état initial) coïncide par construction avec la cible
du clic 2. Mais quand les deux échouent, selon l'ordre d'arrivée des réponses, le dernier rollback
exécuté peut laisser l'UI sur true alors que le serveur n'a jamais quitté false.
Ce bug est quasi invisible en dev, où ta requête revient avant que tu aies recliqué. Il se manifeste chez l'utilisateur au réseau lent, celui-là même que l'optimistic devait servir.
La version qui tient : restaurer du confirmé, pas du deviné
Deux règles, et il faut les deux :
- L'échec d'une requête dépassée par un clic plus récent ne décide jamais d'un rollback. C'est cette règle qui neutralise le rollback périmé de la requête A ci-dessus.
- Un rollback ne restaure pas un snapshot : il restaure la cible de la requête la plus récente, dans l'ordre des clics, que le serveur a acquittée. Et seulement quand plus aucune requête n'est en vol pour cet item, c'est-à-dire une fois la rafale de clics retombée. La règle 1 seule ne suffit pas : si les deux requêtes du double clic échouent, "annuler le dernier clic" ressusciterait la cible du premier, qui a échoué aussi.
Tant qu'on y est, on sort l'état du composant : des favoris affichés sur une carte produit, dans une liste et dans un compteur de header, c'est un état partagé, donc un service. Version complète :
import { computed, inject, Injectable, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Injectable({ providedIn: 'root' })
export class FavoritesStore {
private readonly http = inject(HttpClient);
private readonly ids = signal<ReadonlySet<string>>(new Set());
// Per-item bookkeeping. Plain Maps: none of this drives the view.
private readonly confirmed = new Map<string, boolean>();
private readonly confirmedId = new Map<string, number>();
private readonly inFlight = new Map<string, number>();
private readonly latestRequestId = new Map<string, number>();
private readonly latestFailed = new Map<string, boolean>();
readonly count = computed(() => this.ids().size);
isFavorite(id: string): boolean {
return this.ids().has(id);
}
toggle(id: string, onError: () => void): void {
const target = !this.ids().has(id);
// Nothing in flight: the current value is, by construction, the last
// one the server acknowledged (last success, or last rollback).
if (!this.inFlight.get(id)) {
this.confirmed.set(id, !target);
}
const requestId = (this.latestRequestId.get(id) ?? 0) + 1;
this.latestRequestId.set(id, requestId);
this.inFlight.set(id, (this.inFlight.get(id) ?? 0) + 1);
this.setLocally(id, target);
this.http.put<void>(`/api/favorites/${id}`, { favorite: target }).subscribe({
next: () => {
// Responses can come back out of order: only a more recent
// click may advance the acknowledged value.
if (requestId > (this.confirmedId.get(id) ?? 0)) {
this.confirmed.set(id, target);
this.confirmedId.set(id, requestId);
}
this.settle(id, requestId, false, onError);
},
error: () => this.settle(id, requestId, true, onError),
});
}
private settle(id: string, requestId: number, failed: boolean, onError: () => void): void {
// Only the latest click decides whether the user's intention failed.
if (this.latestRequestId.get(id) === requestId) {
this.latestFailed.set(id, failed);
}
const stillInFlight = (this.inFlight.get(id) ?? 1) - 1;
this.inFlight.set(id, stillInFlight);
// Once the burst is over, roll back to the confirmed value if the
// user's last intention could not be applied.
if (stillInFlight === 0 && this.latestFailed.get(id)) {
this.setLocally(id, this.confirmed.get(id) ?? false);
onError();
}
}
private setLocally(id: string, favorite: boolean): void {
this.ids.update((ids) => {
const next = new Set(ids);
if (favorite) {
next.add(id);
} else {
next.delete(id);
}
return next;
});
}
}
Et le composant redevient ce qu'il aurait toujours dû être : un bouton.
import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core';
import { FavoritesStore } from './favorites-store';
import { ToastService } from './toast.service';
@Component({
selector: 'app-favorite-button',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<button type="button" (click)="toggle()">
{{ favorites.isFavorite(productId()) ? 'Remove from favorites' : 'Add to favorites' }}
</button>
`,
})
export class FavoriteButton {
protected readonly favorites = inject(FavoritesStore);
private readonly toasts = inject(ToastService);
readonly productId = input.required<string>();
protected toggle(): void {
this.favorites.toggle(this.productId(), () =>
this.toasts.show('Could not update favorites, please retry.', 'error'),
);
}
}
Quatre points qui méritent une seconde lecture :
- Le numéro de requête est par item. Deux produits différents ne se gênent pas : chaque
ida sa propre séquence. C'est le clic répété sur le même item qui crée le conflit. confirmeds'initialise tout seul, et n'avance que dans l'ordre des clics. Quand rien n'est en vol pour un item, sa valeur courante est forcément la dernière acquittée : soit le dernier succès, soit un rollback déjà appliqué. Au premier clic après le chargement initial (non montré ici), c'est l'état renvoyé par ton GET. Quant au garde-fourequestId > confirmedId, il empêche deux succès revenus dans le désordre de réinstaller la cible la plus ancienne.- Le rollback attend la fin de la rafale, et ne touche que son item. Tant qu'une requête est
en vol, la valeur acquittée peut encore avancer : restaurer trop tôt figerait une valeur
possiblement dépassée. Et restaurer un snapshot du
Setentier écraserait les toggles des autres items réussis entre-temps : on ne répare que ce que cette rafale a touché. isFavorite()est une méthode, et ça marche avec OnPush. Elle litthis.ids()pendant l'exécution du template, donc le signal est bien suivi : quand leSetchange, la vue se met à jour. Uncomputed()par item n'apporterait ici qu'une couche de plus.
Une limite à connaître : tout ceci suppose que le serveur applique les PUT dans
leur ordre d'envoi. Deux PUT quasi simultanés qui se doublent en route, et le serveur peut garder
l'avant-dernière intention. Avec des valeurs absolues, le désaccord s'arrête là, et le prochain
rechargement le résorbe. Si même ça t'est inacceptable, c'est une file d'attente qu'il te faut
(concatMap, mais c'est un autre sujet), pas de l'optimistic.
Au passage : httpResource et resource() ne remplacent pas ce pattern. La doc de resource()
le réserve explicitement aux lectures, un httpResource annule sa requête en vol dès que ses
params changent, et si leur set() permet bien d'afficher une valeur optimiste localement, un
rechargement du loader l'écrase (c'est le piège 4 de l'article sur
abortSignal). L'écriture reste à assembler toi-même,
vu que sa politique de rollback dépend de ton métier.
Avant / après
Côté utilisateur :
| Pessimiste (attendre) | Optimiste naïf | Optimiste (version finale) | |
|---|---|---|---|
| Feedback au clic | après l'aller-retour HTTP | immédiat | immédiat |
| Bouton pendant la requête | grisé | utilisable | utilisable |
| Clics rapides, tout réussit | impossible (disabled) | dernière intention affichée | dernière intention affichée |
| Clics rapides, une requête ancienne échoue | impossible (disabled) | peut restaurer un état périmé | dernière intention affichée, pas de toast |
| Clics rapides, la dernière requête échoue | impossible (disabled) | restaure un snapshot, parfois périmé | dernière valeur acquittée + toast |
Le coût : une cinquantaine de lignes dans un store, un toast d'erreur, et une conversation avec le back pour avoir un endpoint idempotent. Le gain : chaque interaction répond au clic, quel que soit le réseau.
Quand ne PAS faire d'optimistic
Le pattern repose sur deux hypothèses : l'opération réussit presque toujours, et son résultat est prévisible côté client. Quand l'une des deux tombe, abstiens-toi :
- Opérations irréversibles ou à enjeu : paiement, envoi d'email, suppression définitive. "Oups, en fait non" n'est pas un rollback acceptable après un débit de carte.
- Résultat calculé par le serveur : création d'une entité dont l'id, le slug ou le prix vient du back. Tu peux afficher un placeholder, mais c'est un autre pattern, et les ids temporaires à réconcilier sont un nid à bugs.
- Validation serveur fréquente : si un échec sur quatre est normal (stock, droits, quotas), ton UI passera son temps à se dédire, et un rollback fréquent est pire qu'une attente franche.
- Formulaires de plusieurs champs : l'optimistic brille sur les micro-actions (basculer, voter, réordonner, marquer comme lu). Un objet complet qui échoue en validation, ça se traite avec un état de soumission classique.
Récap actionnable
- Une micro-action à résultat prévisible (basculer, liker, archiver) n'a pas à attendre le réseau : écris le signal au clic, envoie la requête ensuite.
- Envoie la valeur cible (PUT idempotent), jamais un ordre de bascule relatif : l'échec partiel et le retry deviennent imprévisibles avec un toggle serveur.
- L'échec d'une requête dépassée par un clic plus récent ne décide jamais d'un rollback. Mais ça ne suffit pas : mémorise la valeur acquittée la plus récente dans l'ordre des clics (pas des réponses), et restaure-la une fois la rafale retombée si la dernière intention a échoué.
- Toujours un feedback visible en cas de rollback. Silencieux = utilisateur qui doute de ses yeux.
- État partagé entre plusieurs composants = store (un service avec un signal privé et des lectures publiques suffit largement ici).
- Paiement, suppression, résultat serveur imprévisible : reste pessimiste, c'est le bon choix.
Un [disabled]="pending()" sur un bouton de toggle, c'est une question à te poser : tu attends
vraiment une information du serveur, ou tu fais juste patienter l'utilisateur pour un 204 ?