Zruck zum Blog, du Zniachtl
Aug. 2026·2 Min. zum LesnVo da KI gmocht

A einfache State Machine in TypeScript bauen

#typescript#design-patterns#frontend

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.

NeicherA praktischer Leitfodn zu Git-Branching-StrategienElterReflows und Repaints: Warum si deine Seitn ruckelig anfühlt