Die meisten UI-Bugs sind keine falschen Berechnungen, sondern unmögliche Kombinationen: ein Spinner und eine Fehlermeldung gleichzeitig, ein „Bezahlen“-Button, der noch funktioniert, während die Zahlung läuft, ein Wizard, der einen Schritt übersprungen hat. Sie entstehen, wenn ein Bildschirm mit unabhängigen Booleans beschrieben wird — isLoading, hasError, isPaid. Drei Booleans ergeben acht Kombinationen; der Bildschirm hat vielleicht vier legitime.
Eine Zustandsmaschine dreht die Frage um: Liste die Zustände auf, die es wirklich geben kann, und für jeden die Events, die er akzeptiert. Alles andere wird durch Konstruktion unmöglich, nicht durch Disziplin. Mit Signals sind das etwa dreißig Zeilen und keine Bibliothek.
Drei Booleans, acht Kombinationen
Hier die übliche Version. Jeder Handler setzt sein eigenes Flag und vergisst, ein anderes zurückzusetzen — nicht aus Nachlässigkeit, sondern weil nichts dich zwingt, an alle gleichzeitig zu denken. Die Demo unten führt links genau diese Logik aus und rechts eine Maschine.
// ✗ drei unabhängige Flags = 8 Kombinationen; der Bildschirm hat 4 legitime
loading = signal(false);
error = signal<string | null>(null);
data = signal<Order[] | null>(null);
load() {
this.loading.set(true); // vergessen: error.set(null) — der alte Fehler steht noch da
this.api.orders().subscribe({
next: (orders) => this.data.set(orders), // vergessen: loading.set(false)
error: (e) => this.error.set(e.message), // vergessen: loading.set(false) — Spinner UND Fehler
});
}Drei Booleans
loading=false · error=false · data=false
Erreichte unmögliche Kombinationen: 0
Eine Maschine
state = 'idle'
Deaktivierte Buttons = Events, die dieser Zustand nicht akzeptiert.
Der erste Schritt: eine Discriminated Union
Vor jeder Maschine kommt der Zustand in einen Wert, dessen Form von seinem Status abhängt. data gibt es nur bei success und error nur bei error, also lässt sich „ein Fehler zusammen mit Daten“ gar nicht erst schreiben. Im Template verengt @switch den Typ: In @case ('error') ist o.error ein String, nicht vielleicht-undefined.
// ✓ ein Wert; `data` gibt es nur bei success, `error` nur bei error
type Load<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
readonly orders = signal<Load<Order[]>>({ status: 'idle' });Mit @default never; (Angular 21.2) wird ein neuer Status im Typ, den du im Template vergisst, zum Compile-Fehler. Mehr zum neuen Control Flow unter @if, @for, @switch.
Eine Maschine auf Signals, in dreißig Zeilen
Eine Union sagt, was die Zustände sind; eine Maschine sagt auch, wie du zwischen ihnen wechselst. Das Herz ist eine Tabelle: für jeden Zustand die Events, die er akzeptiert, und wohin jedes führt. send() schaut in die Tabelle; ein Event, das der aktuelle Zustand nicht akzeptiert, wird einfach ignoriert. can() beantwortet dieselbe Frage fürs Template, damit Buttons die Regeln nicht in [disabled] kopieren. Guards fügen Bedingungen auf Daten hinzu, und onEnter bündelt Seiteneffekte an einem Ort.
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> {
/** zusätzliche Bedingungen: das Event ist nur erlaubt, wenn sein Guard true liefert */
readonly guards?: Partial<Record<E, (from: S) => boolean>>;
/** Seiteneffekte beim Betreten eines Zustands — ein Ort für API-Aufrufe, Timer, 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(), // niemand kann es von außen .set()
can, // für [disabled], statt Bedingungen in Templates zu kopieren
send(event: E): boolean {
if (!can(event)) return false; // in diesem Zustand nicht erlaubt: nichts passiert
const next = target(event)!;
state.set(next);
options.onEnter?.[next]?.();
return true;
},
};
}Ein Checkout mit Live-Zustandsdiagramm
Ein Bezahlvorgang ist der klassische Fall: Zurück ist ab Versand erlaubt, aber nicht, während die Zahlung läuft, ohne Adresse kommst du nicht aus dem Versand heraus, und eine abgelehnte Karte braucht einen Weg zurück zum Zahlungsschritt. All das sind Zeilen in einer Tabelle.
Zwei Artikel im Warenkorb. NEXT führt zum Versand.
- Hier erscheinen jedes Event und die Transition, die es ausgelöst hat.
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' }, // kein BACK, kein zweites PAY während der Zahlung
failed: { RETRY: 'payment' },
paid: {}, // Endzustand
},
{
guards: {
// der Guard bekommt den aktuellen Zustand: kein Bezug auf `this.machine` in seiner eigenen Definition
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');
}
}
}Schau dir den Store an: Das Template entscheidet nie, welche Buttons erlaubt sind, und keine Komponente ruft die Zahlungs-API auf. Die Bank wird an genau einer Stelle aufgerufen — beim Betreten von paying —, also können ein Doppelklick, ein Zurück-Button oder eine Komponente, die zweimal rendert, die Karte nicht doppelt belasten.
Wann eine Bibliothek: XState
Eine handgeschriebene Maschine reicht für einen Ablauf auf einem Bildschirm. Wenn Zustände verschachtelt sind (ein Checkout in einer App, die offline gehen kann), parallel laufen (Upload und Formular gleichzeitig), von Timern abhängen oder du einen visuellen Editor und generierte Tests willst, ist XState die Standardbibliothek für Statecharts. Die Idee bleibt gleich: Zustände, Events, Transitionen. Auch der Verbindungsstatus auf der Seite zu WebSockets ist eine kleine Maschine.
Wann ja, wann nein
- Mehrstufige Abläufe: Checkout, Onboarding, Wizards, Freigaben.
- Alles Asynchrone mit sichtbarem Status: Laden, Fehler, Retry, Abbrechen.
- Controls mit Modi (Ansicht, Bearbeiten, Ziehen, Vorschau) und Verbindungen, die kommen und gehen — siehe WebSockets.
- Ein einfacher Toggle: Ein boolesches Signal ist perfekt.
- Aus anderen abgeleitete Werte: Das ist computed(), kein Zustand.
- Formulare mit unabhängigen Feldern: Jedes Feld ist sein eigener Zustand; die Formularbibliothek kennt valid, dirty und pending schon.
Häufige Stolperfallen
- Ein Boolean pro Bildschirmteil. Jedes neue Flag verdoppelt die Kombinationen, die niemand getestet hat.
- Ein Status-String ohne Tabelle.
status.set('paid')von überall ist wieder Booleans, nur in einer Variablen. - In Templates kopierte Regeln.
[disabled]="step === 'paying' || !address"an drei Stellen; frag stattdessencan(). - Über Komponenten verstreute Seiteneffekte. Zwei Komponenten reagieren auf „paying“, und die Karte wird doppelt belastet. Nimm
onEnter, einmal. - Ein Zustand ohne Ausweg. „failed“ ohne RETRY oder BACK, und der einzige Ausweg ist, die Seite neu zu laden.
Spickzettel
Symptom Spinner und Fehler gleichzeitig, ein Button, der geht, wenn er nicht sollte
Ursache unabhängige Booleans: n Flags = 2^n Kombinationen, die meisten unmöglich
erster Schritt eine Discriminated Union: { status: 'success'; data } | { status: 'error'; error }
Maschine ein Zustands-Signal + eine Tabelle Zustand → { EVENT: nächster Zustand }
send(event) nur erlaubt, wenn die Tabelle es hat (und der Guard ja sagt); sonst ignoriert
can(event) für [disabled] und zum Ausblenden von Aktionen, nie doppelte Bedingungen
guards Bedingungen auf Daten (eine Adresse, ein gültiges Formular) neben der Tabelle
onEnter Seiteneffekte an EINEM Ort: das Betreten von 'paying' ruft die API auf
Endzustände {}: aus 'paid' kommt nichts mehr heraus
jeder Zustand braucht einen Ausweg (failed → RETRY), sonst bleiben Nutzer hängen
abgeleitete Werte weiterhin computed(); die Maschine entscheidet nur, was passieren KANN
verschachtelt / parallel Zustände in Zuständen, Timer, ein visueller Editor → XState