Eine einfache State Machine in TypeScript bauen
Wer schon einmal eine Komponente mit isLoading, isError, hasData und isEmpty als getrennten Booleans geschrieben hat, hat vermutlich auch schon einen Bug ausgeliefert, bei dem zwei davon gleichzeitig wahr waren und die UI einen Spinner und eine Fehlermeldung übereinandergestapelt zeigte. Eine State Machine behebt das auf Typebene: Statt unabhängiger Flags, die sich zu Unsinn kombinieren können, gibt es zu jedem Zeitpunkt genau einen benannten Zustand mit klar definierten Übergängen dazwischen.
Das Boolean-Suppen-Problem
Das ist die Art von Komponenten-State, die State Machines ersetzen:
interface FetchState {
isLoading: boolean;
isError: boolean;
data: string[] | null;
error: string | null;
}
Vier Felder, sechzehn mögliche Kombinationen, und nur vier davon sind tatsächlich gültig (loading, success, error, idle). Nichts hindert isLoading: true und isError: true daran, gleichzeitig zu existieren – man muss sich nur überall und für immer daran erinnern, das nicht zu tun.
Zustände als Union modellieren
Eine State Machine stellt „was gerade passiert" als einzelne Tagged Union dar statt als Sack voller Flags:
type FetchState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: string[] }
| { status: "error"; message: string };
Jetzt existiert data nur, wenn status "success" ist, und message nur, wenn status "error" ist. Das Narrowing von TypeScript erzwingt das für dich:
function render(state: FetchState): string {
switch (state.status) {
case "idle":
return "Nothing yet.";
case "loading":
return "Loading…";
case "success":
return `Got ${state.data.length} items.`; // data is known to exist here
case "error":
return `Failed: ${state.message}`; // message is known to exist here
}
}
Versuche, im "error"-Zweig auf state.data zuzugreifen, und TypeScript lehnt es zur Compile-Zeit ab – nicht um 2 Uhr nachts, wenn ein Nutzer einen leeren Bildschirm meldet.
Übergänge hinzufügen
Ein Zustand allein ist nur eine Momentaufnahme. Die andere Hälfte einer State Machine ist die Menge erlaubter Übergänge – welche Zustände in welche anderen übergehen können, und bei welchem Ereignis. Modelliere die Ereignisse genauso wie die Zustände:
type FetchEvent =
| { type: "FETCH" }
| { type: "RESOLVE"; data: string[] }
| { type: "REJECT"; message: string }
| { type: "RESET" };
function transition(state: FetchState, event: FetchEvent): FetchState {
switch (state.status) {
case "idle":
if (event.type === "FETCH") return { status: "loading" };
return state;
case "loading":
if (event.type === "RESOLVE") return { status: "success", data: event.data };
if (event.type === "REJECT") return { status: "error", message: event.message };
return state;
case "success":
case "error":
if (event.type === "RESET") return { status: "idle" };
return state;
}
}
Jeder Zweig dieser Funktion legt ausdrücklich fest, was erlaubt ist. Man kann nicht versehentlich von "idle" direkt zu "success" springen – es gibt keinen Codepfad dafür. Braucht eine künftige Anforderung diesen Übergang, muss er bewusst an einer Stelle ergänzt werden, statt als Nebeneffekt davon zu entstehen, dass irgendeine Komponente drei Booleans in der falschen Reihenfolge setzt.
In eine Komponente einbauen
Die Funktion transition ist rein – keine Seiteneffekte, leicht isoliert zu testen. Sie um tatsächliche asynchrone Arbeit zu wickeln ist eine dünne Schicht obendrauf:
class FetchController {
private state: FetchState = { status: "idle" };
private listeners: Array<(s: FetchState) => void> = [];
private setState(next: FetchState) {
this.state = next;
this.listeners.forEach((listener) => listener(next));
}
subscribe(listener: (s: FetchState) => void) {
this.listeners.push(listener);
listener(this.state);
}
async load(url: string) {
this.setState(transition(this.state, { type: "FETCH" }));
try {
const res = await fetch(url);
const data: string[] = await res.json();
this.setState(transition(this.state, { type: "RESOLVE", data }));
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
this.setState(transition(this.state, { type: "REJECT", message }));
}
}
}
Jede Zustandsänderung läuft durch transition, daher gilt die Garantie „nur gültige Kombinationen sind erreichbar" für die gesamte Lebensdauer des Controllers, nicht nur auf Typebene für ein einzelnes Rendern.
Übergänge testen statt Komponenten
Weil transition eine einfache Funktion ohne Abhängigkeiten ist, kann man jeden Pfad testen, ohne etwas zu mounten:
test("FETCH moves idle to loading", () => {
const result = transition({ status: "idle" }, { type: "FETCH" });
expect(result).toEqual({ status: "loading" });
});
test("REJECT during loading carries the error message", () => {
const result = transition(
{ status: "loading" },
{ type: "REJECT", message: "network down" },
);
expect(result).toEqual({ status: "error", message: "network down" });
});
test("FETCH while already loading is a no-op", () => {
const state: FetchState = { status: "loading" };
const result = transition(state, { type: "FETCH" });
expect(result).toBe(state);
});
Der letzte Test ist wichtiger, als er aussieht: Er dokumentiert und erzwingt, dass ein doppeltes FETCH-Ereignis im Zustand „loading" nichts neu startet und keine Anfrage doppelt auslöst.
Wann das übertrieben ist
Ein Zwei-Zustands-Schalter (open / closed) braucht diese Maschinerie nicht – ein einzelner Boolean reicht, und ein Union-Typ wäre nur Zeremonie. Greife zu einer expliziten State Machine, wenn eine Komponente drei oder mehr sich gegenseitig ausschließende Zustände hat, wenn die Übergänge dazwischen die eigentliche Fehlerquelle sind (Ladezustände sind der klassische Fall) oder wenn du immer wieder if (isLoading && !isError && data)-Guards schreibst, die nur dazu da sind, Zustand zu kompensieren, der ungültig sein könnte, aber nicht sein sollte.
Fazit
Booleans multiplizieren sich – vier Flags erzeugen sechzehn Zustände, die meisten davon Unsinn. Eine Tagged Union reduziert das auf genau die tatsächlich gültigen Zustände, und eine einzige transition-Funktion wird zu dem einen Ort, der entscheidet, was als Nächstes passieren kann. Den Rest erzwingt der Compiler.