Majoritatea bug-urilor de interfață nu sunt calcule greșite, ci combinații imposibile: un spinner și un mesaj de eroare în același timp, un buton „Plătește” care încă merge cât timp plata rulează, un wizard care a sărit un pas. Vin din descrierea unui ecran cu booleeni independenți — isLoading, hasError, isPaid. Trei booleeni fac opt combinații; ecranul are poate patru legitime.
O mașină de stări întoarce întrebarea pe dos: listezi stările care pot exista cu adevărat și, pentru fiecare, evenimentele pe care le acceptă. Tot restul devine imposibil prin construcție, nu prin disciplină. Pe signals are cam treizeci de linii și nicio bibliotecă.
Trei booleeni, opt combinații
Iată versiunea obișnuită. Fiecare handler își setează propriul flag și uită să-l reseteze pe altul — nu din neatenție, ci pentru că nimic nu te obligă să te gândești la toate deodată. Demo-ul de mai jos rulează exact logica asta în stânga și o mașină în dreapta.
// ✗ trei flaguri independente = 8 combinații; ecranul are 4 legitime
loading = signal(false);
error = signal<string | null>(null);
data = signal<Order[] | null>(null);
load() {
this.loading.set(true); // uitat: error.set(null) — eroarea veche e încă pe ecran
this.api.orders().subscribe({
next: (orders) => this.data.set(orders), // uitat: loading.set(false)
error: (e) => this.error.set(e.message), // uitat: loading.set(false) — spinner ȘI eroare
});
}Trei booleeni
loading=false · error=false · data=false
Combinații imposibile atinse: 0
O mașină
state = 'idle'
Butoane dezactivate = evenimente pe care starea asta nu le acceptă.
Primul pas: o uniune discriminată
Înainte de orice mașină, pune starea într-o singură valoare a cărei formă depinde de status. data există doar în success, iar error doar în error, așa că „o eroare împreună cu date” nici nu poate fi scrisă. În template, @switch îngustează tipul: în @case ('error'), o.error e un string, nu poate-undefined.
// ✓ o singură valoare; `data` există doar în success, `error` doar în error
type Load<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
readonly orders = signal<Load<Order[]>>({ status: 'idle' });Cu @default never; (Angular 21.2), dacă adaugi un status nou în tip și îl uiți în template, primești o eroare de compilare. Mai multe despre noul control flow în @if, @for, @switch.
O mașină pe signals, în treizeci de linii
O uniune spune ce sunt stările; o mașină spune și cum treci între ele. Inima e un tabel: pentru fiecare stare, evenimentele pe care le acceptă și unde duce fiecare. send() se uită în tabel; un eveniment pe care starea curentă nu îl acceptă e pur și simplu ignorat. can() răspunde la aceeași întrebare pentru template, ca butoanele să nu copieze regulile în [disabled]. Guard-urile adaugă condiții pe date, iar onEnter pune efectele într-un singur loc.
import { signal } from '@angular/core';
export type Transitions<S extends string, E extends string> = {
readonly [state in S]: Partial<Record<E, S>>;
};
export interface MachineOptions<S extends string, E extends string> {
/** condiții în plus: evenimentul e permis doar dacă guard-ul lui întoarce true */
readonly guards?: Partial<Record<E, (from: S) => boolean>>;
/** efecte la intrarea într-o stare — un singur loc pentru apeluri API, timere, analytics */
readonly onEnter?: Partial<Record<S, () => void>>;
}
export function createMachine<S extends string, E extends string>(
initial: S,
transitions: Transitions<S, E>,
options: MachineOptions<S, E> = {},
) {
const state = signal<S>(initial);
const target = (event: E) => transitions[state()][event];
const can = (event: E) =>
target(event) !== undefined && (options.guards?.[event]?.(state()) ?? true);
return {
state: state.asReadonly(), // nimeni nu îl poate .set() din afară
can, // pentru [disabled], în loc să copiezi condiții în template-uri
send(event: E): boolean {
if (!can(event)) return false; // nepermis în starea asta: nu se întâmplă nimic
const next = target(event)!;
state.set(next);
options.onEnter?.[next]?.();
return true;
},
};
}Un checkout, cu diagrama de stări live
Un flux de plată e cazul clasic: înapoi e permis din livrare, dar nu cât timp plata rulează, nu poți ieși din livrare fără adresă, iar un card refuzat are nevoie de un drum înapoi la pasul de plată. Toate astea sunt linii într-un tabel.
Două produse în coș. NEXT duce la livrare.
- Aici apar fiecare eveniment și tranziția pe care a produs-o.
import { Service, inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { createMachine } from './machine';
import { PaymentsApi } from './payments-api';
type Step = 'cart' | 'shipping' | 'payment' | 'paying' | 'paid' | 'failed';
type CheckoutEvent = 'NEXT' | 'BACK' | 'PAY' | 'PAID' | 'DECLINED' | 'RETRY';
@Service()
export class CheckoutStore {
private readonly payments = inject(PaymentsApi);
readonly address = signal('');
readonly error = signal<string | null>(null);
readonly machine = createMachine<Step, CheckoutEvent>(
'cart',
{
cart: { NEXT: 'shipping' },
shipping: { NEXT: 'payment', BACK: 'cart' },
payment: { PAY: 'paying', BACK: 'shipping' },
paying: { PAID: 'paid', DECLINED: 'failed' }, // fără BACK, fără al doilea PAY cât timp se plătește
failed: { RETRY: 'payment' },
paid: {}, // stare finală
},
{
guards: {
// guard-ul primește starea curentă: nicio referință la `this.machine` în propria ei definiție
NEXT: (from) => from !== 'shipping' || this.address().trim() !== '',
},
onEnter: { paying: () => void this.pay() },
},
);
private async pay(): Promise<void> {
try {
await firstValueFrom(this.payments.charge());
this.machine.send('PAID');
} catch (e) {
this.error.set((e as Error).message);
this.machine.send('DECLINED');
}
}
}Uită-te la store: template-ul nu decide niciodată ce butoane sunt permise, iar nicio componentă nu apelează API-ul de plăți. Banca e apelată într-un singur loc — la intrarea în paying — așa că un dublu click, un buton de înapoi sau o componentă randată de două ori nu pot debita cardul de două ori.
Când folosești o bibliotecă: XState
O mașină scrisă de mână ajunge pentru un flux pe un ecran. Când stările se imbrică (un checkout într-o aplicație care poate trece offline), rulează în paralel (upload și formular în același timp), depind de timere sau vrei un editor vizual și teste generate, XState e biblioteca standard pentru statecharts. Ideea nu se schimbă: stări, evenimente, tranziții. Și starea conexiunii de pe pagina WebSockets e o mică mașină.
Când DA, când NU
- Fluxuri în mai mulți pași: checkout, onboarding, wizard-uri, aprobări.
- Orice e asincron cu un status vizibil: încărcare, eroare, reîncercare, anulare.
- Controale cu moduri (vizualizare, editare, drag, preview) și conexiuni care vin și pleacă — vezi WebSockets.
- Un simplu toggle: un signal boolean e perfect.
- Valori derivate din altele: acolo e computed(), nu o stare.
- Formulare cu câmpuri independente: fiecare câmp e propria lui stare; biblioteca de formulare știe deja valid, dirty și pending.
Capcane frecvente
- Un boolean pentru fiecare bucată de ecran. Fiecare flag nou dublează combinațiile pe care nu le-a testat nimeni.
- Un string de status fără tabel.
status.set('paid')de oriunde înseamnă tot booleeni, într-o singură variabilă. - Reguli copiate în template-uri.
[disabled]="step === 'paying' || !address"în trei locuri; întreabăcan()în schimb. - Efecte împrăștiate prin componente. Două componente reacționează la „paying” și cardul e debitat de două ori. Folosește
onEnter, o singură dată. - O stare fără ieșire. „failed” fără RETRY sau BACK, iar singura scăpare e reîncărcarea paginii.
Fișă rapidă
simptom un spinner și o eroare împreună, un buton care merge când n-ar trebui
cauză booleeni independenți: n flaguri = 2^n combinații, majoritatea imposibile
primul pas o uniune discriminată: { status: 'success'; data } | { status: 'error'; error }
mașină un signal de stare + un tabel stare → { EVENIMENT: starea următoare }
send(event) permis doar dacă tabelul îl are (și guard-ul spune da); altfel ignorat
can(event) pentru [disabled] și ascunderea acțiunilor, niciodată condiții duplicate
guards condiții pe date (o adresă, un formular valid) lângă tabel
onEnter efectele într-UN singur loc: intrarea în 'paying' apelează API-ul
stări finale {}: nimic nu poate ieși din 'paid'
orice stare are nevoie de o ieșire (failed → RETRY), altfel utilizatorii rămân blocați
valori derivate tot computed(); mașina decide doar ce SE POATE întâmpla
imbricat / paralel stări în stări, timere, un editor vizual → XState