Eine Quiz-App mit einer State Machine bauen
Erstelle eine Quiz-App mit XState v5 in React. Modelliere vier Screens als State Machine, ersetze Boolean-Flags und sichere den Endzustand.
Eine State Machine ersetzt einen Haufen boolescher Flags durch einen einzelnen benannten Zustand – so kann sich eine React-Komponente immer nur in einem der Screens befinden, die Sie bewusst definiert haben.
Es schleicht sich an. Ein Screen beginnt mit einem isLoading, bekommt ein hasAnswered dazu, dann ein isFinished, und irgendwann kann niemand mehr sagen, welche Kombinationen eigentlich zulässig sind. Dieser Artikel modelliert ein kleines Feature, ein Quiz, als XState-v5-Machine mit vier Zuständen und zeigt, was das gegenüber der Flag-Variante bringt. Als breitere Einführung in die Bibliothek ist OpenReplays XState-Überblick ein guter Einstieg, auch wenn der dortige Code noch aus der Zeit vor v5 stammt; hier bauen wir eine Datei von Anfang bis Ende. Der Code richtet sich nach XState v5 und dem v6-Release von @xstate/react.
Die wichtigsten Erkenntnisse
- Drei boolesche Flags beschreiben acht Kombinationen, ein Quiz hat aber nur vier zulässige Screens – bleiben vier Zustände, die Ihre UI erreichen kann und die in Ihrem Design nie vorgesehen waren.
- In XState beschreiben Zustände, was die Nutzerin gerade tun kann, während der Context die Daten hält, mit denen diese Zustände arbeiten: „answered“ ist ein Zustand, aber die Fragenliste, der aktuelle Index und der Punktestand gehören in den Context.
- In XState v5 ist ein Guard eine Funktion, die
{ context, event }erhält und mit true oder false antwortet; ein false bedeutet, dass die zugehörige Transition übersprungen wird. - Einen Review-Schritt zu einer flag-basierten Komponente hinzuzufügen bedeutet ein viertes Flag und sechzehn Kombinationen; ihn zu einer Machine hinzuzufügen bedeutet einen neuen Zustand und dessen Transitionen.
Das Quiz und seine vier Zustände
Das Quiz hat genau vier Screens, und jeder entspricht einem benannten Zustand. idle: der Startbildschirm, nichts geladen, ein Button. question: eine Frage ist zu sehen und die Optionen sind anklickbar. answered: die Nutzerin hat eine Option gewählt, Feedback ist sichtbar, und ein Next-Button erscheint. results: der Endpunktestand, mit dem Angebot, neu zu starten. Jedes Rendering, das die Komponente jemals erzeugen wird, gehört zu einem dieser vier Zustände.
Warum scheitern boolesche Flags?
Drei boolesche Flags – isLoading, hasAnswered und isFinished – beschreiben acht mögliche Kombinationen, aber das Quiz hat nur vier zulässige Screens. Damit bleiben vier Zustände übrig, die Ihre UI physisch erreichen kann und die Ihr Design nie berücksichtigt hat.
| isLoading | hasAnswered | isFinished | Screen |
|---|---|---|---|
| false | false | false | question |
| true | false | false | nächste Frage wird geladen |
| false | true | false | answered |
| false | false | true | results |
| true | true | false | keiner (answered während des Ladens) |
| true | false | true | keiner |
| false | true | true | keiner |
| true | true | true | keiner |
Die erste unzulässige Zeile ist nicht hypothetisch. So kommt es dazu:
function goToNext() {
setIsLoading(true);
fetchQuestion(index + 1).then((q) => {
setQuestion(q);
setHasAnswered(false); // reset arrives only when the fetch resolves
setIsLoading(false);
});
}
Die Nutzerin antwortet, klickt auf Next, und bis der Fetch aufgelöst ist, hält die Komponente gleichzeitig isLoading: true und hasAnswered: true: ein Spinner neben der hervorgehobenen Antwort der letzten Frage. Nichts verknüpft die beiden Flags, also verhindert auch nichts diesen Zustand. In Session Replays von Quiz- und Wizard-Flows hat diese Fehlerklasse eine wiedererkennbare Signatur: ein Screen, den es nicht geben dürfte – etwa eine hervorgehobene Antwort ohne gerenderte Frage –, und genau so sieht eine nicht aufgezählte Flag-Kombination aus Sicht der Nutzerin aus.
Das Quiz als State Machine in React modellieren
Die Machine benennt die vier Zustände, die Events, die zwischen ihnen wechseln, und sonst nichts – dadurch ist jede Transition explizit und alles Nichtaufgeführte unmöglich. createMachine nimmt die gesamte Definition als ein Objekt entgegen:
import { createMachine, assign } from 'xstate';
const questions = [
{ text: '2 + 2?', options: ['3', '4'], answer: 1 },
{ text: 'Capital of France?', options: ['Paris', 'Lyon'], answer: 0 },
{ text: 'Largest planet?', options: ['Earth', 'Jupiter'], answer: 1 },
];
export const quizMachine = createMachine({
id: 'quiz',
initial: 'idle',
context: { questions, currentIndex: 0, score: 0 },
states: {
idle: {
on: { START: { target: 'question' } },
},
question: {
on: {
ANSWER: {
target: 'answered',
actions: assign({
score: ({ context, event }) =>
event.optionIndex === context.questions[context.currentIndex].answer
? context.score + 1
: context.score,
}),
},
},
},
answered: {
on: {
NEXT: {
target: 'question',
actions: assign({
currentIndex: ({ context }) => context.currentIndex + 1,
}),
},
},
},
results: {
on: {
RESTART: {
target: 'idle',
actions: assign({ currentIndex: 0, score: 0 }),
},
},
},
},
});
Ein ANSWER-Event im Zustand results bewirkt nichts – konstruktionsbedingt, nicht durch defensive if-Prüfungen. Eine Lücke bleibt: results wird bisher nie erreicht. Der Abschnitt zu Guards schließt sie.
Context versus State: Daten sind kein Zustand
Zustände beschreiben, was die Nutzerin gerade tun kann; der Context hält die Daten, mit denen diese Zustände arbeiten. Für dieses Quiz ist „answered“ ein Zustand, weil sich dadurch ändert, welche Bedienelemente funktionieren, während die Fragenliste, der aktuelle Index und der Punktestand in den Context gehören, weil sie verändern, was innerhalb eines Screens angezeigt wird, nicht aber, welcher Screen es ist. Der Test ist übertragbar: Wenn ein Wert verändert, was die Nutzerin tun kann, modellieren Sie ihn als Zustand; wenn er verändert, was sie innerhalb desselben Screens sieht, gehört er in den Context und wird mit assign aktualisiert, dessen Callback wie oben gezeigt { context, event } erhält. Die Fragen hart zu kodieren hält diesen Artikel fokussiert; um sie über das Netzwerk zu laden, rufen Sie in einem Lade-Zustand einen fromPromise-Actor auf und weisen mit assign aus event.output zu.
Die Machine in eine React-Komponente einbinden
Der useMachine-Hook aus @xstate/react liefert drei Dinge in einem Array zurück: den aktuellen Snapshot, eine send-Funktion und eine Referenz auf den laufenden Actor. Die folgende Komponente nutzt die ersten beiden, rendert anhand von snapshot.value und meldet Nutzeraktionen als Events, statt Flags zu setzen.
import { useMachine } from '@xstate/react';
import { quizMachine } from './quizMachine';
export default function Quiz() {
const [snapshot, send] = useMachine(quizMachine);
const { questions, currentIndex, score } = snapshot.context;
const question = questions[currentIndex];
if (snapshot.value === 'idle')
return <button onClick={() => send({ type: 'START' })}>Start quiz</button>;
if (snapshot.value === 'results')
return (
<div>
<p>Score: {score} / {questions.length}</p>
<button onClick={() => send({ type: 'RESTART' })}>Play again</button>
</div>
);
return (
<div>
<p>{question.text}</p>
{question.options.map((option, i) => (
<button
key={option}
disabled={snapshot.value === 'answered'}
onClick={() => send({ type: 'ANSWER', optionIndex: i })}
>
{option}
</button>
))}
{snapshot.value === 'answered' && (
<button onClick={() => send({ type: 'NEXT' })}>Next</button>
)}
</div>
);
}
In den Handlern gibt es keine Flag-Buchhaltung. Die Komponente stellt Tatsachen fest („die Nutzerin hat geantwortet“), und die Machine entscheidet, was sie bedeuten.
Wie sichert man die Endbedingung mit einem Guard ab?
Ein Guard ist eine kleine Prüfung ohne Seiteneffekte. XState übergibt ihm den Context der Machine und das gerade eingetroffene Event, und er antwortet mit true oder false. Ein false bedeutet, dass die Transition, an der er hängt, übersprungen wird. Um die letzte Antwort nach results zu leiten, ersetzen Sie die NEXT-Transition durch ein Array von Guarded Transitions. XState arbeitet dieses Array von oben nach unten ab, verwendet den ersten Eintrag, dessen Guard true liefert, und erreicht den ungeschützten Eintrag am Ende nur dann, wenn keiner davon zutrifft:
answered: {
on: {
NEXT: [
{
guard: ({ context }) =>
context.currentIndex >= context.questions.length - 1,
target: 'results',
},
{
target: 'question',
actions: assign({
currentIndex: ({ context }) => context.currentIndex + 1,
}),
},
],
},
},
Die Endbedingung liegt nun an einer einzigen Stelle, statt in jedem Handler, der den Index anfasst, erneut hergeleitet zu werden.
Was bringt Ihnen die Machine?
Die unzulässigen Zeilen aus der obigen Tabelle sind jetzt unerreichbar, nicht bloß unwahrscheinlich: Keine Eventfolge versetzt die Machine in „answered während des Ladens“, weil keine Transition dorthin führt. Der zweite Gewinn betrifft Änderungen. Einen Review-Schritt zur booleschen Variante hinzuzufügen bedeutet ein viertes Flag und sechzehn Kombinationen, über die man nachdenken muss; ihn zur Machine hinzuzufügen bedeutet einen neuen Zustand review, eine Transition von results dorthin und eine wieder hinaus. Die Komponente bekommt einen if-Zweig, und alle bestehenden Zustände verhalten sich weiterhin genau wie zuvor.
Wo Sie dieses Muster als Nächstes einsetzen
Das Muster skaliert weiter nach unten, als die meisten Tutorials nahelegen: Jeder Screen, bei dem Sie sich beim Schreiben von if (isX && !isY) ertappen, ist ein Kandidat für vier oder fünf benannte Zustände. Nehmen Sie die Machine-Datei aus diesem Artikel, tauschen Sie die Zustände und Events Ihrer eigenen Domäne ein, und machen Sie die Fehlerklasse aus jener Kombinationstabelle zu etwas, das Ihre Komponente gar nicht mehr ausdrücken kann.
FAQs
Was ist der Unterschied zwischen einer XState-Machine und useReducer?
Beide zentralisieren Transitionen in einer Funktion, aber ein Reducer akzeptiert jede Action in jedem Zustand, sodass unzulässige Kombinationen ausdrückbar bleiben. Eine State Machine reagiert nur auf Events, die unter ihrem aktuellen Zustand aufgeführt sind: Ein ANSWER-Event, das im Zustand results eintrifft, wird konstruktionsbedingt ignoriert. Ein Reducer lässt zudem die Menge der möglichen Zustände implizit in seinen Daten, während eine Machine jeden Zustand explizit benennt.
Setzt XState v5 TypeScript voraus?
Nein. XState v5 läuft in reinem JavaScript, und jedes Snippet in diesem Artikel funktioniert ohne Typen. Wenn Sie TypeScript verwenden, ist Version 5.0 die niedrigste unterstützte Version. Mit TypeScript können Sie über die setup()-API Typen für Context und Events deklarieren, sodass Transitionen und assign-Aufrufe zur Compile-Zeit geprüft werden.
Läuft Tutorial-Code für XState v4 auch unter v5?
Nein, bei der Migration von v4 auf v5 wurden mehrere zentrale APIs umbenannt. Aus Machine() wurde createMachine(), aus interpret() wurde createActor(), die Eigenschaft cond an Transitionen heißt jetzt guard, und aus invoked services wurden Actors. Auch die Callback-Signaturen haben sich geändert: Actions und Guards erhalten nun ein einzelnes Objekt mit context und event statt separater Argumente. Wenn ein Snippet cond oder interpret verwendet, ist es v4-Code und läuft nicht unter v5.
Können sich zwei React-Komponenten denselben XState-Machine-State teilen?
Nicht dadurch, dass man useMachine zweimal aufruft: Jeder Aufruf erzeugt einen eigenständigen Actor mit eigenem Snapshot, sodass zwei Komponenten mit useMachine(quizMachine) getrennte, nicht synchronisierte Zustände halten. Um eine laufende Machine gemeinsam zu nutzen, erzeugen Sie den Actor einmal und verteilen ihn – entweder mit createActorContext aus dem xstate-react-Paket oder indem Sie die von useMachine zurückgegebene actorRef als Prop weiterreichen und mit useSelector auslesen.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k