ステートマシンでクイズアプリを作る
ReactでXState v5を使いクイズアプリを構築。4画面を状態機械でモデル化し、booleanフラグを置き換え、結果状態を制御します。
ステートマシンは、山積みになったブール値フラグを単一の名前付きステートで置き換えます。これにより、React コンポーネントは意図的に定義した画面のいずれか 1 つの状態にしか存在できなくなります。
これは気づかないうちに忍び寄ってきます。画面は最初 isLoading が 1 つだけあり、そこに hasAnswered が加わり、次に isFinished が加わり、ある時点で誰もどの組み合わせが正当なのか言えなくなります。この記事では、クイズという 1 つの小さな機能を 4 つのステートを持つ XState v5 のマシンとしてモデル化し、フラグ版に対してどのような利点が得られるかを示します。ライブラリのより広範な入門としては、OpenReplay の XState 概説記事 が良い出発点になりますが、そのコードは v5 より前のものです。ここでは 1 つのファイルを最初から最後まで作り上げます。コードは XState v5 と @xstate/react の v6 リリースを対象としています。
要点
- 3 つのブール値フラグは 8 通りの組み合わせを表現しますが、クイズには正当な画面が 4 つしかありません。つまり、UI が到達しうるにもかかわらず設計で一切考慮されていないステートが 4 つ残ることになります。
- XState では、ステートはユーザーが今この瞬間に何をできるかを表し、context はそれらのステートが操作するデータを保持します。「answered」はステートですが、問題リスト、現在のインデックス、スコアは context に属します。
- XState v5 における guard は
{ context, event }を受け取り true か false を返す関数であり、false が返るとその guard が付いた遷移はスキップされます。 - フラグベースのコンポーネントにレビューステップを追加すると、4 つ目のフラグと 16 通りの組み合わせが生まれます。マシンに追加する場合は、新しいステート 1 つとその遷移だけで済みます。
クイズと 4 つのステート
このクイズにはちょうど 4 つの画面があり、それぞれが 1 つの名前付きステートに対応します。idle: 開始画面。何もロードされておらず、ボタンが 1 つ。question: 問題が画面に表示され、選択肢がクリック可能。answered: ユーザーが選択肢を選び、フィードバックが表示され、Next ボタンが現れる。results: 最終スコアと、リスタートの提示。このコンポーネントが今後生成するあらゆるレンダリングは、この 4 つのいずれかに属します。
なぜブール値フラグは破綻するのか
isLoading、hasAnswered、isFinished という 3 つのブール値フラグは 8 通りの組み合わせを表現しますが、クイズには正当な画面が 4 つしかありません。つまり、UI が物理的に到達できるのに設計では一切考慮されていないステートが 4 つ残るということです。
| isLoading | hasAnswered | isFinished | 画面 |
|---|---|---|---|
| false | false | false | question |
| true | false | false | 次の問題のロード中 |
| false | true | false | answered |
| false | false | true | results |
| true | true | false | 該当なし(ロード中に回答済み) |
| true | false | true | 該当なし |
| false | true | true | 該当なし |
| true | true | true | 該当なし |
最初の不正な行は仮定の話ではありません。次のようにして発生します。
function goToNext() {
setIsLoading(true);
fetchQuestion(index + 1).then((q) => {
setQuestion(q);
setHasAnswered(false); // reset arrives only when the fetch resolves
setIsLoading(false);
});
}
ユーザーが回答し、Next をクリックすると、fetch が解決するまでコンポーネントは isLoading: true と hasAnswered: true を同時に保持します。つまり、前の問題のハイライトされた回答の隣にスピナーが表示された状態です。2 つのフラグは互いに何の関係も持たないため、これを防ぐものは何もありません。クイズやウィザードフローのセッションリプレイでは、この種のバグには見分けやすい特徴があります。存在すべきでない画面、たとえば問題が描画されていないのにハイライトされた回答があるといったもので、これは列挙されていないフラグの組み合わせがユーザー側からどう見えるかを表しています。
クイズを React でステートマシンとしてモデル化する
このマシンは 4 つのステート、それらの間を移動するイベント、そしてそれ以外のものは何も定義しません。したがってすべての遷移は明示的であり、列挙されていないものはすべて不可能になります。createMachine は定義全体を 1 つのオブジェクトとして受け取ります。
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 }),
},
},
},
},
});
results で ANSWER イベントを受け取っても何も起こりません。これは防御的な if チェックによるものではなく、構造上そうなっているのです。1 つだけ欠けている点があります。まだ results に到達する経路がありません。guard のセクションでこれを埋めます。
context とステートの違い: データはステートではない
ステートはユーザーが今この瞬間に何をできるかを表し、context はそれらのステートが操作するデータを保持します。このクイズにおいて「answered」がステートであるのは、どのコントロールが機能するかを変えるからです。一方、問題リスト、現在のインデックス、スコアが context であるのは、画面そのものを変えるのではなく、画面内で表示される内容を変えるからです。この判断基準は他のケースにも応用できます。ある値がユーザーができることを変えるならステートとしてモデル化し、同じ画面内で見えるものを変えるだけなら context に入れて assign で更新します。assign のコールバックは上記のとおり { context, event } を受け取ります。問題をハードコードしているのはこの記事の焦点を絞るためです。ネットワーク経由でロードする場合は、ローディングステートで fromPromise アクター を invoke し、event.output から assign してください。
マシンを React コンポーネントに組み込む
@xstate/react の useMachine フック は配列で 3 つのものを返します。現在のスナップショット、send 関数、そして実行中のアクターへの参照です。以下のコンポーネントは最初の 2 つを取り出し、snapshot.value からレンダリングを行い、ユーザーのアクションをフラグの設定ではなくイベントとして報告します。
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>
);
}
ハンドラー内にフラグの管理は一切ありません。コンポーネントは事実(「ユーザーが回答した」)を伝えるだけで、その意味はマシンが決定します。
終了条件はどのように guard するのか
guard は副作用を持たない小さなチェックです。XState はマシンの context と、たった今到着したイベントを guard に渡し、guard は true か false を返します。false が返ると、その guard が付いた遷移はスキップされます。最後の回答を results にルーティングするには、NEXT 遷移を guard 付き遷移 の配列に置き換えます。XState はその配列を上から順に処理し、guard が true を返す最初のエントリを使用します。どれも true を返さない場合にのみ、末尾の guard なしのエントリに到達します。
answered: {
on: {
NEXT: [
{
guard: ({ context }) =>
context.currentIndex >= context.questions.length - 1,
target: 'results',
},
{
target: 'question',
actions: assign({
currentIndex: ({ context }) => context.currentIndex + 1,
}),
},
],
},
},
終了条件は、インデックスを扱うすべてのハンドラーで再導出されるのではなく、1 箇所にまとまりました。
マシンによって何が得られるのか
上の表にあった不正な行は、単に起こりにくいのではなく、到達不可能になりました。「ロード中に回答済み」に至る遷移が存在しないため、どんなイベントの並びでもマシンをその状態にすることはできません。2 つ目の利点は変更への対応です。ブール値版にレビューステップを追加すると、4 つ目のフラグと 16 通りの組み合わせについて考える必要が生じます。マシンに追加する場合は、新しい review ステート 1 つ、results からそこへの遷移、そしてそこから出る遷移 1 つで済みます。コンポーネントには if 分岐が 1 つ増えるだけで、既存のすべてのステートは以前とまったく同じように振る舞い続けます。
次にこのパターンを使う場所
このパターンは、多くのチュートリアルが示すよりもさらに小さな規模にまでスケールダウンできます。if (isX && !isY) を書いている自分に気づいた画面はどれも、4 つか 5 つの名前付きステートの候補です。この記事のマシンファイルを持っていき、自分のドメインのステートとイベントに差し替え、あの組み合わせ表にあったような種類のバグを、コンポーネントが表現できないものにしてしまいましょう。
FAQ
XState のマシンと useReducer の違いは何ですか?
どちらも遷移を 1 つの関数に集約しますが、reducer はどのステートでもあらゆるアクションを受け付けるため、不正な組み合わせが表現可能なまま残ります。ステートマシンは現在のステートの下に列挙されたイベントにのみ応答します。results ステートで受け取った ANSWER イベントは、構造上無視されます。また reducer は取りうるステートの集合をデータの中に暗黙的に残しますが、マシンは各ステートを明示的に名前付けします。
XState v5 に TypeScript は必須ですか?
いいえ。XState v5 は素の JavaScript で動作し、この記事のすべてのスニペットは型なしで動きます。TypeScript を使う場合、サポートされる最小バージョンは 5.0 です。TypeScript を使うと、setup() API によって context とイベントの型を宣言でき、遷移や assign の呼び出しがコンパイル時にチェックされます。
XState v4 のチュートリアルのコードは v5 で動きますか?
いいえ。v4 から v5 への移行でいくつかのコア API が改名されました。Machine() は createMachine() に、interpret() は createActor() になり、遷移の cond プロパティは guard に、invoke されるサービスはアクターになりました。コールバックのシグネチャも変わり、アクションと guard は個別の引数ではなく context と event を含む単一のオブジェクトを受け取るようになりました。スニペットが cond や interpret を使っていればそれは v4 のコードであり、v5 では動きません。
2 つの React コンポーネントで同じ XState マシンのステートを共有できますか?
useMachine を 2 回呼ぶ方法ではできません。呼び出しごとに独自のスナップショットを持つ独立したアクターが生成されるため、useMachine(quizMachine) を使う 2 つのコンポーネントは別個で同期されないステートを保持します。実行中の 1 つのマシンを共有するには、アクターを一度だけ作成して配布します。xstate の react パッケージの createActorContext を使うか、useMachine が返す actorRef を prop として下位に渡し、useSelector で読み取ります。
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