A einfache State Machine in TypeScript bauen
Wennst scho amoi a Komponente mit isLoading, isError, hasData und isEmpty ois separate Booleans gschriebn host, hast wahrscheinlich a an Bug ausgliefert, wo zwoa davon gleichzeitig true woarn und die UI an Spinner und a Fehlermeldung übereinander zeigt hot. A State Machine behebt des auf Typ-Ebene: Statt unabhängiger Flags, de si zu Blödsinn kombiniern kinnan, hast genau oan benannten Zustand zur Zeit, mit klar definierten Übergängen dazwischen.
Des Boolean-Suppen-Problem
So schaut der Komponenten-State aus, den 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 sand tatsächlich gültig (loading, success, error, idle). Nix hindert isLoading: true und isError: true dran, gleichzeitig zum existiern - du muasst nur dran denken, des nie zum machn, überall, für immer.
Zustände ois Union modelliern
A State Machine stellt "wos grod passiert" ois einzelne getaggte Union dar, statt ois Sackl voll Flags:
type FetchState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: string[] }
| { status: "error"; message: string };
Jetzt existiert data nur, wenn status "success" is, und message nur, wenn status "error" is. TypeScripts Control-Flow-Narrowing erzwingt des für di:
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
}
}
Probier, im "error"-Zweig auf state.data zuzugreifn, und TypeScript lehnt's beim Kompilieren ab - ned um 2 in da Nacht, wenn a Nutzer an leeren Bildschirm meldet.
Übergänge dazubauen
A Zustand alloa is nur a Momentaufnahme. Die ondere Hälfte von ana State Machine is die Menge der erlaubten Übergänge - wöche Zustände in wöche ondern wechseln dürfen, und bei wöchem Event. Modellier die Events genauso wia 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 is explizit darüber, wos erlaubt is. Du kannst ned versehentlich von "idle" direkt auf "success" springa - es gibt koan Codepfad, der des mocht. Wenn a künftige Anforderung den Übergang braucht, muass er bewusst an oaner Stelle ergänzt wern, statt ois Nebeneffekt davon zu entstehn, dass irgendwo a Komponente drei Booleans in falscher Reihenfolge setzt.
In a Komponente einbinden
Die transition-Funktion is pur - koane Seiteneffekte, leicht isoliert zum Unit-Testn. Sie um echte asynchrone Orbeit zu wickeln, is a 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 geht durch transition, drum gilt die Garantie "nur gültige Kombinationen sand erreichbar" für die gesamte Lebensdauer vom Controller, ned nur auf Typ-Ebene für oan Render.
Übergänge testn statt Komponenten
Weil transition a normale Funktion ohne Abhängigkeiten is, kannst jeden Pfad testn, ohne irgendwos zum Mountn:
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 is wichtiger, ois er ausschaut: Er dokumentiert und erzwingt, dass a doppeltes FETCH-Event, während scho geladen wird, nix neu startet und ka Request doppelt feuert.
Wann des übertrieben is
A Zwoa-Zustands-Schalter (open / closed) braucht die Mechanik ned - a einzelnes Boolean reicht, und a Union-Typ wär nur Zeremonie. Greif zu ana expliziten State Machine, wenn a Komponente drei oda mehr sich gegenseitig ausschließende Zustände hot, wenn die Übergänge dazwischen die eigentliche Bug-Quelle sand (Ladezustände sand der Klassiker), oda wennst dich dabei ertappst, if (isLoading && !isError && data)-Guards zum schreiben, de nur existieren, um Zustand zum kompensiern, der ungültig sei kunnt, aber ned sei soit.
Fazit
Booleans vermehren si - vier Flags erzeugen sechzehn Zustände, de meisten Blödsinn. A getaggte Union reduziert des auf genau die Zustände, de tatsächlich gültig sand, und a einzelne transition-Funktion wird zum oanzigen Ort, der entscheidet, wos als Nächstes passiern kann. Den Rest erzwingt der Compiler.