Să aștepți serverul înainte să schimbi ecranul e cinstit, dar lent: un like care apare după 400 ms pare stricat. O actualizare optimistă schimbă interfața imediat, presupune că cererea va reuși și anulează schimbarea dacă nu reușește.
Ideea încape într-o propoziție. Aplicațiile se strică la detalii: ce anume restaurezi când pică, ce se întâmplă când două răspunsuri vin în ordinea greșită și cum evită serverul să aplice aceeași acțiune de două ori când reîncerci.
Tiparul: ții minte, aplici, confirmi sau anulezi
Trei pași. Ții minte valoarea curentă, o schimbi local, trimiți cererea. La succes, răspunsul serverului înlocuiește presupunerea ta — poate să difere (un id, un timestamp, text curățat). La eșec, restaurezi ce ai ținut minte și îi spui utilizatorului: o valoare care sare înapoi fără niciun mesaj arată ca 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. ecranul se schimbă ACUM
try {
const saved = await firstValueFrom(this.api.update(id, { done: !before.done }));
this.patch(id, saved); // 2. câștigă versiunea serverului (timestamp-uri, text normalizat...)
} catch {
this.patch(id, { done: before.done }); // 3. anulăm DOAR acest element...
this.error.set(`Nu am putut salva „${before.title}”. Am anulat.`); // ...și o spunem
}
}
private patch(id: string, change: Partial<Todo>): void {
this.todos.update((list) => list.map((t) => (t.id === id ? { ...t, ...change } : t)));
}
}Interfața
Salvat pe server
- Scrie notele de release
- Revizuiește pull request-ul
- Actualizează dependențele
- Repară testul instabil
- efect vizibil după
- —
- cereri
- 0
- eșuate
- 0
- interfață ≠ server
- 0
Anulează ce ai schimbat, nu toată lista
Să salvezi un snapshot al întregii liste e varianta cea mai tentantă și e greșită de îndată ce două cereri sunt pe drum. Snapshot-ul luat de prima cerere nu conține a doua modificare; când prima pică, restaurarea lui șterge și o modificare pe care serverul a salvat-o deja. Scenariul din demo face exact asta: interfața și serverul ajung să nu mai fie de acord, fără nimic care să le repare. Anulează per element sau aplică operația inversă.
// ✗ Un snapshot al ÎNTREGII liste prinde și celelalte modificări aflate încă pe drum
async toggle(id: string) {
const snapshot = this.todos(); // luat înainte de următorul click al utilizatorului...
this.patch(id, { done: !this.find(id).done });
try {
await firstValueFrom(this.api.update(id, ...));
} catch {
this.todos.set(snapshot); // ...deci anulează și o modificare pe care serverul a salvat-o DEJA
}
}Crearea optimistă mai are un pas. Elementul nou nu are încă un id real, așa că îi dai unul temporar și îl înlocuiești când răspunde serverul. Până atunci, editările acelui element trebuie să aștepte sau folosesc id-ul temporar, pe care serverul nu îl cunoaște.
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 }));
// id-ul temporar dispare: orice editare ulterioară folosește id-ul real
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}” nu a fost adăugat.`);
}
}Răspunsuri care vin în ordinea greșită
Pornește o setare, oprește-o și pornește-o din nou, repede. Pleacă trei cereri și nimic nu garantează ordinea în care se întorc răspunsurile. Dacă fiecare răspuns suprascrie starea, câștigă ultimul răspuns sosit — nu ultima alegere a utilizatorului.
Fiecare click trimite o cerere. Bara ei se termină când sosește răspunsul.
- Ai ales
- off
- Câștigă ultimul răspuns
- off …
- Doar cererea cea mai nouă
- off …
„Câștigă ultimul răspuns” a greșit de 0 ori din 0.
Două remedii, pentru două probleme diferite. Doar cea mai nouă: fiecare cerere primește un număr și doar răspunsul la cea mai nouă are voie să scrie — asta repară interfața. Dar și serverul poate primi cererile în altă ordine. Dacă starea finală de pe server contează, serializează scrierile cu concatMap (câte o cerere, în ordinea click-urilor) sau trimite o versiune pe care serverul o verifică. Pentru căutări, unde contează doar ultima, switchMap le anulează pe cele vechi; vezi operatorii higher-order.
private latest = 0;
readonly enabled = signal(false); // ce vede utilizatorul (optimist)
private readonly confirmed = signal(false); // ce a confirmat serverul ultima dată
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);
// un răspuns mai vechi, sosit târziu, nu are voie să suprascrie o alegere mai nouă
if (id === this.latest) this.enabled.set(saved.notifications);
} catch {
if (id === this.latest) this.enabled.set(this.confirmed());
}
}Idempotență: o acțiune, aplicată o singură dată
Interfețele optimiste reîncearcă, iar aici e capcana: cererea a ajuns la server, comanda s-a creat și doar răspunsul s-a pierdut. O reîncercare creează a doua comandă. Remediul e un Idempotency-Key: un UUID generat o singură dată per acțiune a utilizatorului și trimis la fiecare reîncercare. Serverul ține minte cheile procesate și, pentru o repetare, întoarce rezultatul original în loc să refacă munca.
import { HttpClient, HttpContext, HttpContextToken, HttpInterceptorFn } from '@angular/common/http';
import { retry } from 'rxjs';
export const IDEMPOTENCY_KEY = new HttpContextToken<string | null>(() => null);
/** Cheia călătorește în contextul cererii și devine header. */
export const idempotencyInterceptor: HttpInterceptorFn = (req, next) => {
const key = req.context.get(IDEMPOTENCY_KEY);
return next(key ? req.clone({ setHeaders: { 'Idempotency-Key': key } }) : req);
};
// O cheie per ACȚIUNE A UTILIZATORULUI, refolosită de fiecare reîncercare a ei.
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 })); // același observable → același context → aceeași cheie
}Cheia ține de acțiunea utilizatorului, nu de cererea HTTP: generează-o când utilizatorul apasă butonul, nu într-un interceptor care ar face una nouă la fiecare reîncercare. Reîncercările sunt tratate în HttpClient și interceptori.
Când DA, când NU
- Acțiuni frecvente și ieftine, care reușesc aproape mereu: like-uri, bife, marcarea ca citit.
- Modificări ușor de anulat: reordonare, redenumire, cantitatea dintr-un coș.
- Interfețe unde latența se simte la fiecare click: liste, board-uri, editoare.
- Plăți și orice implică bani: utilizatorul are nevoie de o confirmare reală.
- Acțiuni ireversibile: trimiterea unui email, ștergerea definitivă, publicarea.
- Validare care pică des pe server (un username deja luat): ai anula tot timpul.
Capcane frecvente
- Rollback tăcut. Bifa dispare fără niciun mesaj; utilizatorul crede că aplicația e stricată sau nu observă și merge mai departe.
- Un snapshot al întregii liste. Anulează și alte modificări aflate pe drum, inclusiv unele pe care serverul le-a salvat deja.
- Câștigă ultimul răspuns. Un răspuns vechi care sosește târziu suprascrie cea mai nouă alegere. Numerotează cererile.
- Reîncercare fără cheie de idempotență. Un răspuns pierdut devine o comandă dublă.
- Nicio reconciliere cu serverul. Id-uri temporare niciodată înlocuite, text normalizat care nu se mai întoarce: interfața se îndepărtează încet de date.
Fișă rapidă
actualizare optimistă snapshot → aplici acum → trimiți → versiunea serverului | rollback + mesaj
rollback doar ce a schimbat cererea asta (elementul / operația inversă)
nu toată lista un snapshot al listei anulează și alte cereri aflate pe drum
răspunsul serverului înlocuiește presupunerea locală (id-uri, timestamp-uri, text normalizat)
id-uri temporare tmp-uuid până răspunde „create”, apoi îl schimbi cu id-ul real
răspunsuri în dezordine un id per cerere: doar cea mai nouă are voie să scrie (newest-only)
scrieri în ordine concatMap: câte una, în ordinea click-urilor
citiri switchMap: anulezi căutarea veche, o păstrezi pe cea nouă
reîncercări Idempotency-Key = un UUID per acțiune, același la fiecare reîncercare
niciodată optimist plăți, acțiuni ireversibile, validare care pică des
rollback fără mesaj = o interfață care pare stricată