Zum Inhalt springen

Finite State Machines für komplexe UI-Logik in TypeScript

Ersetze Boolean-Flag-Spaghetti durch Finite State Machines: ein TypeScript-Ansatz, der unmögliche UI-Zustände konstruktionsbedingt ausschließt.

4 Min. Lesezeit
TypeScript-Code, der eine Finite-State-Machine-Transitionsdefinition für einen Formular-Submit-Flow zeigt

Boolean-Flags sind eine Steuer. Jeder neue Edge-Case fügt ein weiteres isLoading, hasError, isRetrying, wasSubmitted hinzu — und schon pflegst du ein Dutzend Booleans, die sich zu Zuständen kombinieren können, die nie existieren sollten. Ein Formular, das gleichzeitig isLoading und hasError wegen eines vorherigen Versuchs ist. Ein Button, der aus drei verschiedenen Gründen disabled ist, von denen keiner explizit ist. Finite State Machines (FSMs) eliminieren diese ganze Klasse von Bugs auf Typ-Ebene.

Das Boolean-Spaghetti-Problem

Hier ist eine typische asynchrone Formularkomponente. Sie ist vertraut, weil wir sie alle schon geschrieben haben.

tstypescript
// ❌ Implicit states — combinations like (loading=true, error=true) are possible
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSuccess, setIsSuccess] = useState(false);
const [isRetrying, setIsRetrying] = useState(false);
 
async function handleSubmit() {
  setIsLoading(true);
  setError(null); // Easy to forget this reset
  try {
    await submitForm(data);
    setIsSuccess(true);
  } catch (e) {
    setError(e.message);
  } finally {
    setIsLoading(false);
  }
}

Die Oberfläche möglicher Zustandskombinationen ist 2⁴ = 16. Deine Komponente rendert vielleicht vier davon sinnvoll, aber das Type-System kann dir das nicht sagen. Tests müssen gegen Zustände absichern, die logisch nicht existieren können, aber technisch schon.

tstypescript
// ✅ Explicit states — only valid combinations exist
type FormState =
  | { status: "idle" }
  | { status: "submitting" }
  | { status: "success" }
  | { status: "error"; message: string }
  | { status: "retrying"; attempt: number };

Eine discriminated union. Fünf benannte Zustände. Keine unmöglichen Kombinationen.

Transitionen als Machine modellieren

Eine Finite State Machine hat drei Zutaten: States, Events und Transitionen. Die Machine befindet sich zu jedem Zeitpunkt in genau einem Zustand und wechselt zwischen Zuständen nur, wenn ein definiertes Event feuert.

tstypescript
type FormState =
  | { status: "idle" }
  | { status: "submitting" }
  | { status: "success" }
  | { status: "error"; message: string };
 
type FormEvent =
  | { type: "SUBMIT" }
  | { type: "RESOLVE" }
  | { type: "REJECT"; message: string }
  | { type: "RESET" };
 
function formReducer(state: FormState, event: FormEvent): FormState {
  switch (state.status) {
    case "idle":
      if (event.type === "SUBMIT") return { status: "submitting" };
      return state;
 
    case "submitting":
      if (event.type === "RESOLVE") return { status: "success" };
      if (event.type === "REJECT") return { status: "error", message: event.message };
      return state;
 
    case "error":
      if (event.type === "SUBMIT") return { status: "submitting" };
      if (event.type === "RESET") return { status: "idle" };
      return state;
 
    case "success":
      if (event.type === "RESET") return { status: "idle" };
      return state;
  }
}

Der entscheidende Punkt: unbehandelte Transitionen werden stillschweigend ignoriert (return state). Ein RESOLVE-Event, das im Zustand idle feuert, tut nichts. Das eliminiert die gesamte Kategorie von Bugs mit falscher Operationsreihenfolge, ohne defensive Codierung im Aufrufer.

Mit React verbinden

Der Reducer lässt sich direkt auf useReducer abbilden. Für Machines dieser Größe ist keine externe Bibliothek nötig.

tstypescript
function ContactForm() {
  const [state, dispatch] = useReducer(formReducer, { status: "idle" });
 
  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    dispatch({ type: "SUBMIT" });
 
    try {
      await submitContactForm(new FormData(e.currentTarget as HTMLFormElement));
      dispatch({ type: "RESOLVE" });
    } catch (err) {
      dispatch({
        type: "REJECT",
        message: err instanceof Error ? err.message : "Something went wrong",
      });
    }
  }
 
  return (
    <form onSubmit={handleSubmit}>
      {state.status === "error" && (
        // TypeScript narrows here — `message` only exists in the error state
        <p role="alert">{state.message}</p>
      )}
      {state.status === "success" ? (
        <p>Thanks — we'll be in touch.</p>
      ) : (
        <button disabled={state.status === "submitting"}>
          {state.status === "submitting" ? "Sending…" : "Send"}
        </button>
      )}
    </form>
  );
}

state.message im Error-Zweig wird durch die discriminated union eingeschränkt — diese Eigenschaft existiert nur, wenn status === "error". Kein optional chaining, kein Runtime-Guard. Der Compiler erzwingt Korrektheit.

Guards und erweiterter State

Echte Machines brauchen oft Guards — Bedingungen, die erfüllt sein müssen, bevor eine Transition feuert. Ein Checkout-Flow könnte PROCEED nur erlauben, wenn cart.items.length > 0. Guards halten Geschäftsregeln neben der Transition, die sie schützen, statt sie über Event-Handler verteilt zu haben.

tstypescript
type CheckoutState =
  | { status: "cart"; items: CartItem[] }
  | { status: "payment"; items: CartItem[]; total: number }
  | { status: "confirming"; orderId: string }
  | { status: "complete"; orderId: string };
 
type CheckoutEvent =
  | { type: "PROCEED" }
  | { type: "ORDER_CREATED"; orderId: string }
  | { type: "CONFIRM" };
 
function checkoutReducer(state: CheckoutState, event: CheckoutEvent): CheckoutState {
  switch (state.status) {
    case "cart": {
      if (event.type !== "PROCEED") return state;
      // Guard: can't proceed with an empty cart
      if (state.items.length === 0) return state;
 
      const total = state.items.reduce(
        (sum, item) => sum + item.price * item.qty,
        0,
      );
      return { status: "payment", items: state.items, total };
    }
 
    case "payment": {
      if (event.type === "ORDER_CREATED") {
        return { status: "confirming", orderId: event.orderId };
      }
      return state;
    }
 
    case "confirming": {
      if (event.type === "CONFIRM") {
        return { status: "complete", orderId: state.orderId };
      }
      return state;
    }
 
    default:
      return state;
  }
}

Die Berechnung von total passiert genau einmal — zum Zeitpunkt der Transition — und wird im Kontext der Machine gespeichert. Kein abgeleiteter State, der bei jedem Render neu berechnet wird, kein Risiko, dass er veraltet ist, während eine Geschwisterkomponente aktualisiert.

Machines isoliert testen

Weil der Reducer eine pure Funktion (state, event) => state ist, lässt er sich trivial testen, ohne eine Komponente zu mounten oder Hooks zu mocken.

tstypescript
describe("formReducer", () => {
  it("transitions from idle to submitting on SUBMIT", () => {
    const next = formReducer({ status: "idle" }, { type: "SUBMIT" });
    expect(next).toEqual({ status: "submitting" });
  });
 
  it("ignores RESOLVE while idle", () => {
    const state = { status: "idle" } as const;
    const next = formReducer(state, { type: "RESOLVE" });
    expect(next).toBe(state); // Same reference — no allocation
  });
 
  it("captures error message on REJECT", () => {
    const next = formReducer(
      { status: "submitting" },
      { type: "REJECT", message: "Network timeout" },
    );
    expect(next).toEqual({ status: "error", message: "Network timeout" });
  });
});

Jeder Test liest sich wie eine Spezifikation: bei diesem Zustand, wenn dieses Event feuert, erwarte dieses Ergebnis. Keine gemounteten Bäume, keine act()-Wrapper, kein async-Setup. Die gesamte Transitionstabelle lässt sich mit einigen Dutzend schneller Unit-Tests erschöpfend abdecken.

Wann man auf eine Bibliothek zurückgreift

Handgeschriebene Reducer decken die meisten UI-Fälle ab. Für komplexere Orchestrierung — parallele Zustände, hierarchische Machines, verzögerte Transitionen, Aufruf asynchroner Services — ist XState v5 das richtige Werkzeug. Die v5-API ist deutlich schlanker als v4 und integriert sich sauber in React via useMachine.

~

Du brauchst XState nicht für einen Drei-Zustand-Toggle. Nutze es, wenn deine Machine Services aufrufen, Timer verwalten oder parallele Regionen koordinieren muss. Ein einfachen Fetch-Wrapper zu einem vollständigen State Chart zu über-engineern, ist sein eigenes Anti-Pattern.

KomplexitätEmpfohlener Ansatz
2–4 Zustände, lineare TransitionenDiscriminated union + useState
4–8 Zustände, Guards, berechneter KontextTypisierter Reducer mit useReducer
Parallele Regionen, asynchrone Aufrufe, verschachtelte ZuständeXState v5

Der Gradient zählt. Ein Drei-Zustand-Button braucht keinen State Chart. Ein Multi-Step-Checkout mit Hintergrund-Polling, Timeout-Recovery und gleichzeitiger Validierung schon.

Wichtige Erkenntnisse

  1. Ersetze Boolean-Flags durch discriminated unions — status: "idle" | "submitting" | "error" | "success" macht unmögliche Zustandskombinationen auf Type-Ebene unmöglich.
  2. Transitionen sind die Source of Truth — definiere, welche Events in jedem Zustand gültig sind, und gib für alles andere unverändert state zurück.
  3. Guards gehören in den Reducer — Geschäftsregeln, die eine Transition kontrollieren, sollten neben der Transition selbst leben, nicht über Komponenten verteilt.
  4. Pure Reducer sind trivial testbar — kein Komponenten-Mounten, kein async-Zeremoniell; teste jedes (state, event)-Paar als direkten Funktionsaufruf.
  5. Greife zu XState, wenn Machines hierarchisch werden — parallele Regionen und asynchrone Service-Aufrufe sind genau das, wofür es gebaut wurde; kämpfe nicht mit einem handgeschriebenen Reducer darum.
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX