Mit der zunehmenden Digitalisierung der Welt verändern sich auch viele der Prozesse, auf die wir uns täglich verlassen. Ob es um die Bestellung von Essen, die Buchung einer Reise oder das Ausfüllen eines Steuererklärungsformulars geht, sind digitale Lösungen mittlerweile die Norm und bieten Komfort, Effizienz und Skalierbarkeit. Die Entwicklung solcher digitalen Prozesse ist oft anspruchsvoller, als es auf den ersten Blick scheint. Die Herausforderung besteht darin, komplexe Abläufe in einer benutzerfreundlichen Oberfläche zu verpacken, um Eingaben zu erfassen – sei es auf einer Website oder in einer nativen Anwendung – und diese Daten in den späteren Phasen der Anwendung effizient weiterzuverarbeiten.
Ein Beispiel dafür ist das Online-Shopping. Nachdem der Kunde seine gewünschten Artikel ausgewählt hat, werden beim Auschecken verschiedene Daten wie z.B. die Adresse des Kunden, Zahlungsoptionen und -informationen sowie die Versandart abgefragt. Dies sollte so implementiert werden, dass der Benutzer in der Lage ist, ohne Datenverlust durch den Prozess hin und her zu navigieren.
Auch unser Team stand vor einer ähnlichen Herausforderung: Ein komplexer, dynamischer Antragsprozess sollte in eine intuitive digitale Anwendung transformiert werden. Anders als bei herkömmlichen statischen Formularen erforderte dieser Prozess eine hohe Flexibilität. Er sollte in der Lage sein, unterschiedliche Schritte und Optionen basierend auf den Eingaben der Benutzer anzuzeigen, Informationen in Echtzeit zu validieren und die Nutzer nahtlos durch den mehrstufigen Ablauf zu führen.
Dieser Artikel behandelt eine der zentralen Herausforderungen bei der Entwicklung solcher Systeme: Die Verwaltung der Prozessschritten mittels einer Finite State Machine und deren nahtlose Integration in eine React-Anwendung.
Anforderungen der Anwendung
Unser Team hat einen vollständig digitalisierten Antragsprozess mit React entwickelt. Dabei mussten verschiedene Herausforderungen gelöst werden, um eine wartbare und technisch robuste Lösung zu gewährleisten.
Bei der ersten Herausforderung handelt es sich um die Erfassung und Verwaltung von Nutzerdaten. Eine große Menge an Informationen wird während des Prozesses gesammelt. Es ist wichtig, dass diese Daten während der gesamten Anwendung konsistent bleiben. Die Benutzer sollen in der Lage sein, zwischen den verschiedenen Schritten des Prozesses zu navigieren, ohne dass es zu Datenverlust oder Inkonsistenzen kommt. Hierfür ist ein robustes internes Zustandsmanagementsystem bedeutsam, welches die eingegebenen Daten effizient speichert und aktualisiert, auch wenn sich die Nutzer flexibel im Prozess bewegen.
Die Eingaben des Nutzers haben auch einen erheblichen Einfluss auf den Ablauf der Anwendung. Das heißt, dass die Daten, welche in einem Schritt gesammelt wurden, über die erforderlichen Informationen bei den nächsten Schritten entscheiden können. Diese Dynamik stellt auch eine große Herausforderung sowohl bei der technischen Umsetzung als auch für die Wartbarkeit des Codes dar. Eine Implementierung, die z.B. viele verschachtelte „if“-Anweisungen enthält, wäre zwar leicht umzusetzen, aber würde jedoch die Lesbarkeit des Codes verschlechtern und dadurch die Wartbarkeit der Anwendung erschweren. Zudem stößt diese Methode bei wachsenden Anforderungen an ihre Grenzen, da sie schlecht skaliert. Aus diesem Grund musste eine Lösung entwickelt werden, die diese Abhängigkeiten ohne unnötige Verkomplizierung der Codebasis flexibel und klar definiert.
Diese Herausforderungen führt uns zu dem etablierten Konzept der Zustandsautomaten. Wie können wir aber Zustandsautomat in einer React-Anwendung implementieren?
Finite State Machine
Ein endlicher Zustandsautomat (Finite State Machine, FSM) ist ein Modell, welches das Verhalten eines Systems beschreibt, indem es in klar definierte Zustände unterteilt wird. Zu jedem Zeitpunkt befindet sich das System in genau einem dieser Zustände. Ein Wechsel zwischen den Zuständen erfolgt durch bestimmte Ereignisse oder Eingaben, wobei der aktuelle Zustand und die Eingabe gemeinsam bestimmen, was als Nächstes passiert.
Ein einfaches Beispiel für eine FSM ist ein Ampelsystem:

Dieses FSM-Diagramm stellt eine Verkehrsampel dar, die sich zwischen drei Zuständen bewegt: Rot, Grün und Gelb. Jeder Zustand bleibt aktiv, bis eine bestimmte Zeit vergeht, woraufhin ein Übergang zum nächsten Zustand erfolgt. Dies verdeutlicht, wie FSMs zur Steuerung von Abläufen eingesetzt werden können, indem sie Zustände und deren Übergänge klar definieren.

Oben ist ein Beispiel eines Zustandsautomaten für den Anwendungsfall des Online-Shoppings dargestellt. Der Schwerpunkt liegt hier auf dem Checkout, bei dem es mehrere Zustände gibt, die einen Prozess darstellen, sowie bedingte Zustandsübergänge auf der Basis der eingegebenen Informationen.
Jeder Zustand im Diagramm repräsentiert eine Phase bzw. Seite im Checkoutprozess, in dem bestimmte Informationen erfasst werden. Neben den Hauptzuständen existieren auch Subzustände, die spezifische Teilschritte innerhalb eines Hauptzustands darstellen. Die Übergänge zwischen diesen Zuständen sind oft konditional gesteuert, sodass je nach Eingabe oder Situation unterschiedliche Pfade eingeschlagen werden. Dadurch wird sichergestellt, dass der Prozess flexibel auf verschiedene Szenarien reagieren kann.
Ein Beispiel dafür ist der Zustand Address, der den Subzustand AddressInput enthält. Hier gibt der Benutzer zunächst eine Adresse ein, die validiert wird. Falls festgestellt wird, dass es sich um eine internationale Adresse handelt, erfolgt ein konditionaler Übergang in den Subzustand IntlAddressInfo, in dem zusätzliche Informationen wie Zollangaben oder regionale Besonderheiten erfasst werden.
Dieses Verhaltensmodell passt ideal zu unserem Anwendungsfall, da jeder Schritt als ein Zustand dargestellt werden kann. Der strukturierte Ansatz des FSM gewährleistet Klarheit darüber, wie die Zustände miteinander verbunden sind. Diese Klarheit hilft bei der zukünftigen Wartung der Anwendung.
Mehrere Bibliotheken wurden für die Implementierung eines FSM in Betracht gezogen. Da das Projekt auf React basiert, musste die ausgewählte Bibliothek mit JavaScript kompatibel sein. Zudem war eine Unterstützung für TypeScript wünschenswert, um die Typensicherheit des Codes zu gewährleisten.
Eine der Bibliotheken heißt little-state-machine. Dies ist eine sehr kleine Bibliothek, die im Wesentlichen einen globalen Speicher einschließlich Persistierung bietet. Allerdings fehlen wesentliche Funktionen wie konditionale Übergänge, und Traceability, die für die Anwendung erforderlich sind, weshalb sie nicht ausgewählt wurde.
Eine weitere Bibliothek, welche wir evaluiert haben, ist robot. Diese Bibliothek bietet viel mehr Funktionen als little-state-machine, unter anderem eine deklarative Methode zur Interpretation von Übergängen. Bei der Evaluation im Jahr 2021 schied die Bibliothek für uns aus, da sie nicht so viele Funktionen bietet, wie es für den Anwendungsfall wünschenswert gewesen wäre. So gibt es beispielsweise keine Tutorials und keine Anleitung zur Erstellung von Tests.
Dies führt uns zu XState, welches eine weit verbreitete Bibliothek zum Designen und Implementieren von Zustandsautomaten (siehe Showcase) mit einer hervorragenden Dokumentation ist. Die Bibliothek bietet konditionelle Übergänge, Store-Management und auch eine Testbibliothek, die beim Unit-Test des Zustandsautomaten hilft.
Kriterium | little-state-machine | robot | XState |
Konditionale Übergänge | Nein | Ja | Ja |
Visualisierung | Nein | Nein | Ja |
Dokumentation | Grundlegend | Sehr gut | Sehr gut |
Tests | Eingeschränkte Tutorials | Eingeschränkte Tutorials | Umfangreiche Dokumentation |
Einrichtung von Xstate
Definieren des Zustandsautomaten
Um XState in einem Projekt zu implementieren, muss zunächst der Zustandsautomat deklariert werden, der alle möglichen Zustände und Übergänge innerhalb der Anwendung beschreibt. In unserem Beispiel eines Online-Shopping-Prozesses, wie im obigen Diagramm dargestellt, durchläuft die User Journey verschiedene Zustände wie Browsing, Hinzufügen von Artikeln zum Warenkorb, Eingabe von Adressdaten, Anwendung von Rabatten, Zahlung und Bestätigung der Bestellung. Im Folgenden ist ein Beispiel aufgeführt, wie ein solcher Checkout-Prozess deklariert werden könnte.
import { createMachine } from 'xstate';
const checkoutMachine = createMachine(
{
id: 'checkout',
initial: 'browsing',
states: {
browsing: {
on: {
NEXT: 'cart',
},
},
cart: {
on: {
NEXT: 'address',
},
},
address: {
...
},
remainingStoreCredit: {
...
},
payment: {
...
},
confirmation: {
...
},
},
}
);
Jeder Zustand repräsentiert einen Schritt im Prozess, und die Übergänge (hier z.B. das NEXT-Event) steuern, wie Benutzer durch den Ablauf navigieren. In unserem Projekt gibt es zudem ein PREV-Event, das analog zum NEXT-Event funktioniert, jedoch hier nicht separat dargestellt wird, da das zugrunde liegende Konzept identisch ist. Die explizite Struktur verbessert die Wartbarkeit und macht Workflows auch für Entwickler intuitiver verständlich. Darüber hinaus bietet XState Entwicklern Tools zur Visualisierung von Zustandsübergängen, was bei der Pflege und Dokumentation der Codebasis von Vorteil sein kann.
Sub-State und konditionaler Übergang
XState unterstützt auch Sub-States und konditionale Übergänge, die es Workflows ermöglichen eine detailliertere und kontextabhängige Logik darzustellen. Im Code würde das wie folgt aussehen:
{
id: 'checkout',
initial: 'browsing',
states: {
...,
address: {
initial: 'addressInput',
states: {
addressInput: {
on: {
NEXT: [{ target: 'intlAddressInfo', guard: 'isInternational' }],
},
},
intlAddressInfo: {
...
},
},
on: {
NEXT: [{ target: 'remainingStoreCredit', guard: 'hasRemainingStoreCredit' }, { target: 'payment' }],
},
},
remainingStoreCredit: {
...
},
...,
},
},
{
guards: {
isInternational: (context, event) => context.address.isInternational,
hasRemainingStoreCredit: (context, event) => context.hasRemainingStoreCredit,
},
}
Sub-States wie „addressInput“ und „intlAddressInfo“ ermöglichen es, dass zustandsspezifisches Verhalten auf einer höheren Abstraktionsebene zu strukturieren. Dadurch wird die Skalierbarkeit und Verständlichkeit für komplexe Prozesse verbessert. Wenn ein Benutzer beispielsweise eine internationale Adresse angibt, wechselt der Automat in den Zustand intlAddressInfo, wo eine bestimmte Logik (z. B. das Sammeln von Zollinformationen) abgewickelt wird.
Konditionale Übergänge erlauben ein kontrolliertes Routing basierend auf dem Kontext. Im Beispiel wird auf das NEXT-Event unterschiedlich reagiert je nachdem, ob die Adresse international ist (isInternational) oder ob es noch eine Gutschrift auf dem Konto gibt (hasRemainingStoreCredit). Auf diese Weise wird sichergestellt, dass Übergänge nur dann stattfinden, wenn logische Bedingungen erfüllt sind, was eine detaillierte Steuerung der Anwendungsworkflows ermöglicht.
Kontext und Entry Actions
Eine der nützlichen Funktionen von XState ist die Fähigkeit, den Kontext, einen zentralen Speicher für interne Daten, zu verwalten und zu manipulieren. In unserer Implementierung speichert der Kontext beispielsweise den am weitesten entfernten Schritt der User Journey und ermöglicht so Funktionen wie persistente Navigation und Zustandswiederherstellung nach einem Seitenrefresh.
Mithilfe der „Entry Actions“ von XState kann der Kontext immer dann aktualisiert werden, wenn ein bestimmter Zustand erreicht wird. Ein Beispiel ist der Zustand der Adresse in unserer Maschine:
address: {
entry: [
{
type: UPDATE_FURTHEST_STEPS,
params: { /* Additional metadata can go here */ }
},
],
},
Hier wird ein Entry Event UPDATE_FURTHEST_STEPS ausgelöst, wenn der Zustand der Adresse erreicht ist. Diese Aktion ist wie folgt implementiert:
const UPDATE_FURTHEST_STEPS = assign<Context, AnyEventObject, SubprocessMetaData, EventObject, ProvidedActor>(
(assignArgs, params) => {
const { context } = assignArgs;
const [positionOfFurthestProcess, positionOfFurthestSubProcess] = context.meta.furthestProcessSubProcessIndex;
const [positionOfCurrentProcess, positionOfCurrentSubProcess] = params.processSubprocessIndex;
// Check if updating the furthest step is allowed
if (
positionOfFurthestProcess < positionOfCurrentProcess ||
(positionOfFurthestProcess === positionOfCurrentProcess &&
positionOfFurthestSubProcess < positionOfCurrentSubProcess)
) {
return {
...context,
meta: {
...context.meta,
furthestProcessSubProcessIndex: params.processSubprocessIndex,
},
};
}
return context;
}
);
In diesem Beispiel stellt die Aktion sicher, dass der am weitesten fortgeschrittene Schritt der User Journey nur dann aktualisiert wird, wenn der Benutzer einen wirklich neuen Status im Workflow erreicht. Durch die Speicherung und Persistenz dieses Kontexts (z. B. im localStorage des Browsers) wird eine konsistente Erfahrung gewährleistet, auch wenn der Benutzer hin und her navigiert oder die Seite aktualisiert.
Integration von XState in eine React-Anwendung
Im Folgenden wird gezeigt wie XState in React integriert werden kann. Die Funktion createWizardMachine kapselt die Initialisierung des Zustandsautomaten. Der React Hook useWizardMachine verwendet den Hook useMaschine und kapselt die Synchronisierung der React-States sowie die Synchronisierung des Kontextes der Finite State Machine.
import * as React from 'react';
import { createMachine } from 'xstate';
import { useMachine } from '@xstate/react';
import { Context } from './your-path-to-context-type';
import { guards } from './your-path-to-guards';
import { actions } from './your-path-to-actions';
import checkoutMachineConfig from './your-path-to-machine-config';
export const createWizardMachine = (history: H.History, currentPath: string) => {
// initialization of the state machine
...
const machine = createMachine({...checkoutMachineConfig, context: initialContext}, { guard, actions })
...
return machine
};
export const useWizardMachine = () => {
const location = useLocation();
const history = useHistory();
const machine = React.useMemo(
() => createWizardMachine(history, location?.pathname),
[],
);
const [state, send] = useMachine(machine);
const updateLocalStorage = React.useCallback(
(context: Partial<Context>) => {
const storedContext = {
...context,
};
localStorage.setItem('checkout-machine', JSON.stringify(storedContext));
},
[],
);
React.useEffect(() => {
updateLocalStorage(state.context);
}, [state.context, updateLocalStorage]);
return [state, send];
};
Dieser Ansatz stellt sicher, dass der Kontext zur Laufzeit immer konsistent ist. Durch die Kapselung der XState-Zustandsmaschine in einem benutzerdefinierten React-Hook (useWizardMachine) wird die Logik sauber von den einzelnen Komponenten abstrahiert, wodurch die Implementierung wiederverwendbar und besser testbar wird. Der Hook vereinfacht den Zugriff auf den Zustand und das Senden von Ereignissen in der gesamten Anwendung. Darüber hinaus wird durch diese Kapselung unnötiger Code reduziert, da die Einrichtungslogik wie z. B. die Initialisierung der Maschine und die Synchronisierung des localStorage an einem einzigen wiederverwendbaren Ort zusammengefasst wird.
Die Speicherung des Kontexts im localStorage ergänzt diesen Ansatz, indem sie robuste Anwendungsfälle wie die Navigation vor und zurück und die Aktualisierung der Seite ermöglicht. Der Zustandsautomat fungiert in diesem Szenario als „Skelett“ für den Workflow, wodurch Entwickler die Möglichkeit haben, sich auf die schrittweise Erweiterung des Kontexts zu konzentrieren und bedingte Übergänge zu integrieren. Dies steigert die Flexibilität und Anpassungsfähigkeit für zukünftige Funktionserweiterungen erheblich.
Testing von Xstate State-Maschine
Das Testen eines Zustandsautomaten ist wichtig, um sicherzustellen, dass seine Zustände, Übergänge und Logik korrekt funktionieren. Das folgende Beispiel zeigt, wie man einen XState-Automaten testet, um sowohl die Anfangszustände als auch komplexe Übergänge zu überprüfen:
describe('The StateMachine should', () => {
const setup = (initialContext={}) => {
const machine = {
...config,
...initialContext,
};
return createMachine(machine, {
guards,
actions,
});
};
const stepFromStartToRequestConfirmationPage = (stateMachine) => {
// The steps are as follows:
// browsing: 1 step
// cart: 1 steps
// address: 2 steps
// discountCheck: 3 steps
// payment: 1 steps
// confirmation: 1 step
for (let numberOfSteps = 0; numberOfSteps < 1 + 1 + 2 + 3 + 1 + 1; numberOfSteps += 1) {
stateMachine.send({ type: 'NEXT' });
}
};
const runTransitionTest = (
verify: (passedStates: string[], state: AnyState) => void,
execute?: (state: StateMachine) => void,
) => {
let passedStates: string[] = [];
const stateMachine = interpret(setup(context));
stateMachine.onTransition((state) => {
passedStates = addCurrentState(passedStates, state);
verify(passedStates, state);
});
stateMachine.start();
execute?.(stateMachine);
};
test('start in state "browsing"', (done) => {
const verify = (passedStates, state) => {
if (state.matches('browsing')) {
done();
}
};
runTransitionTest(verify);
});
test('be completable', (done) => {
const verify = (passedStates, state) => {
if (state.matches('confirmation')) {
done();
}
};
const execute = (stateMachine) => {
stepFromStartToRequestConfirmationPage(stateMachine);
};
runTransitionTest(verify, execute);
});
});
Um das Benutzerverhalten zu simulieren, sendet die Funktion stepFromStartToRequestConfirmationPage mehrere NEXT-Ereignisse, um den Automaten durch verschiedene Zustände zu bewegen. Dabei kommt der initialContext zum Einsatz, der es ermöglicht, individuelle Startbedingungen oder Benutzereingaben (wie vorausgefüllte Formulardaten) zu simulieren. Dies bietet die Flexibilität, unterschiedliche Szenarien präzise nachzubilden.
Der Ansatz unterstreicht einen zentralen Vorteil von State Machines: Die Geschäftslogik wird losgelöst von der Benutzeroberfläche implementiert und getestet. Selbst wenn sich die UI ändern, bleibt die Logik von der Zustandsmaschine stabil. Durch isoliertes Testen wird auch die Zuverlässigkeit der Arbeitsabläufe noch sichergestellt, ohne von externen Faktoren abhängig zu sein.
Lesson learned
Die Nutzung von XState im Projekt hat gezeigt, welche Vorteile Zustandsautomaten bei der Steuerung komplexer Anwendungsworkflows bieten. Dabei haben wir wertvolle Erfahrungen gesammelt, die uns geholfen haben, die Stärken von XState gezielt in einem Projekt einzusetzen. Ein großer Pluspunkt ist, dass XState für eine klare und strukturierte Verwaltung der Zustände sorgt. Da alle Zustände und Übergänge explizit definiert werden, bleibt der Ablauf der Anwendung vorhersehbar und lässt sich leichter warten. Die eingebaute Visualisierung kann ebenfalls von Vorteil sein. In unserem Anwendungsfall allerdings war die Visualisierung aufgrund der Komplexität des Zustandsautomaten unübersichtlich und schwerer zu verstehen als der Code selbst.
Ein weiterer Vorteil sind die umfangreichen Features von XState. Mit Konzepten wie Guards, Actions und Context unterstützt die Bibliothek modulare und wartbare Logik. Die in XState integrierte Store-Verwaltung (Context) ist ebenfalls ein großer Vorteil, da sie die Datenkonsistenz in der gesamten Anwendung sicherstellt und die Integration mit Guards, Actions und Events erleichtert.
Allerdings entstanden während des Integrationsprozesses gewisse Herausforderungen. XState ist mit einer erheblichen Lernkurve verbunden, insbesondere für Teams, die mit Zustandsautomaten nicht vertraut sind. Konzepte wie hierarchische Zustände und Guards erfordern Zeit und Mühe, um sie vollständig zu verstehen. Bei einfachen Workflows kann XState einen zu großen Overhead darstellen und zu mehr Komplexität in der Anwendung führen.
Die Verwendung von XState in Kombination mit TypeScript stellte zusätzliche Hürden dar. Während TypeScript die Typsicherheit verbessert und Laufzeitfehler reduziert, führte die Definition von Typen für die Maschine, den Kontext, die Zustände und die Events in komplexen Maschinen zu zusätzlicher Komplexität. Die Verwaltung dieser „Typgymnastik“ kann beim Schreiben und Erweitern neuer Funktionalitäten für den Zustandsautomaten eine Herausforderung darstellen.
Insgesamt hat sich XState als ein leistungsfähiges und flexibles Werkzeug für die Verwaltung von komplexen Workflows erwiesen und bietet viele Vorteile für Anwendungen, welche eine robuste Zustandsverwaltung benötigen. Bei einfachen Anwendungen kann es allerdings zu insgesamt mehr Komplexität führen. Potenzielle Nachteile, wie die steilere Lernkurve und die umfangreiche Konfiguration, sollten daher sorgfältig abgewogen werden, bevor XState für ein bestimmtes Projekt eingesetzt wird.