パターン1: マルチステップフォームの状態管理
ECサイトの会員登録やサブスクリプション申し込みなど、複数ステップにまたがるフォームは状態管理の典型的な難所です。
設計のポイント
マルチステップフォームをステートマシンで設計する最大の利点は、不正な遷移を型レベルで防止 できることです。例えば「住所入力が完了していないのに決済画面に遷移する」といった問題を、コンパイル時に検出できます。
import { setup, assign } from 'xstate' ;
// フォームデータの型定義
interface FormContext {
personalInfo : {
name : string ;
email : string ;
} | null ;
address : {
postalCode : string ;
prefecture : string ;
city : string ;
line1 : string ;
} | null ;
payment : {
cardToken : string ;
last4 : string ;
} | null ;
errors : Record < string , string >;
currentStep : number ;
}
type FormEvent =
| { type : 'SUBMIT_PERSONAL' ; data : FormContext [ 'personalInfo' ] }
| { type : 'SUBMIT_ADDRESS' ; data : FormContext [ 'address' ] }
| { type : 'SUBMIT_PAYMENT' ; data : FormContext [ 'payment' ] }
| { type : 'BACK' }
| { type : 'VALIDATION_ERROR' ; errors : Record < string , string > }
| { type : 'RETRY' };
const multiStepFormMachine = setup ({
types: {
context: {} as FormContext ,
events: {} as FormEvent ,
},
guards: {
isPersonalInfoValid : ({ event }) => {
if (event.type !== 'SUBMIT_PERSONAL' ) return false ;
const d = event.data;
return !! d && d.name. length > 0 && d.email. includes ( '@' );
},
isAddressValid : ({ event }) => {
if (event.type !== 'SUBMIT_ADDRESS' ) return false ;
const d = event.data;
return !! d && d.postalCode. length === 7 && d.city. length > 0 ;
},
},
actions: {
savePersonalInfo: assign ({
personalInfo : ({ event }) =>
event.type === 'SUBMIT_PERSONAL' ? event.data : null ,
currentStep: 2 ,
}),
saveAddress: assign ({
address : ({ event }) =>
event.type === 'SUBMIT_ADDRESS' ? event.data : null ,
currentStep: 3 ,
}),
savePayment: assign ({
payment : ({ event }) =>
event.type === 'SUBMIT_PAYMENT' ? event.data : null ,
}),
setErrors: assign ({
errors : ({ event }) =>
event.type === 'VALIDATION_ERROR' ? event.errors : {},
}),
clearErrors: assign ({ errors: {} }),
},
}). createMachine ({
id: 'multiStepForm' ,
initial: 'personalInfo' ,
context: {
personalInfo: null ,
address: null ,
payment: null ,
errors: {},
currentStep: 1 ,
},
states: {
personalInfo: {
on: {
SUBMIT_PERSONAL: [
{
guard: 'isPersonalInfoValid' ,
target: 'address' ,
actions: [ 'clearErrors' , 'savePersonalInfo' ],
},
{
target: 'personalInfo' ,
actions: {
type: 'setErrors' ,
// ガードに失敗した場合のエラー設定
},
},
],
},
},
address: {
on: {
SUBMIT_ADDRESS: [
{
guard: 'isAddressValid' ,
target: 'payment' ,
actions: [ 'clearErrors' , 'saveAddress' ],
},
{ target: 'address' },
],
BACK: 'personalInfo' ,
},
},
payment: {
on: {
SUBMIT_PAYMENT: {
target: 'processing' ,
actions: 'savePayment' ,
},
BACK: 'address' ,
},
},
processing: {
invoke: {
src: 'submitOrder' ,
onDone: 'success' ,
onError: 'paymentError' ,
},
},
paymentError: {
on: {
RETRY: 'payment' ,
},
},
success: {
type: 'final' ,
},
},
});
React コンポーネントとの統合
// React での使用例(@xstate/react v5)
import { useMachine } from '@xstate/react' ;
function CheckoutForm () {
const [ state , send ] = useMachine (multiStepFormMachine, {
actors: {
submitOrder: fromPromise ( async ({ input }) => {
const res = await fetch ( '/api/checkout' , {
method: 'POST' ,
body: JSON . stringify (input),
});
if ( ! res.ok) throw new Error ( '決済に失敗しました' );
return res. json ();
}),
},
});
// 現在のステップに応じたコンポーネントを表示
return (
< div >
< StepIndicator current = {state.context.currentStep} />
{ state . matches (' personalInfo ') && (
< PersonalInfoStep
onSubmit = {(data) => send ({ type : 'SUBMIT_PERSONAL' , data })}
errors = {state.context.errors}
/>
)}
{ state . matches (' address ') && (
< AddressStep
onSubmit = {(data) => send ({ type : 'SUBMIT_ADDRESS' , data })}
onBack = {() => send ({ type : 'BACK' })}
/>
)}
{ state . matches (' payment ') && (
< PaymentStep
onSubmit = {(data) => send ({ type : 'SUBMIT_PAYMENT' , data })}
onBack = {() => send ({ type : 'BACK' })}
/>
)}
{ state . matches (' processing ') && < LoadingSpinner />}
{ state . matches (' success ') && < SuccessMessage />}
{ state . matches (' paymentError ') && (
< ErrorMessage onRetry = {() => send ({ type : 'RETRY' })} />
)}
</ div >
);
}
パターン2: 認証フロー — OAuth・MFA・セッション管理
モダンな認証フローは、OAuth リダイレクト、多要素認証(MFA)、トークンリフレッシュなど、多数の状態遷移を含みます。これをステートマシンで設計すると、セキュリティの抜け漏れを防げます。
import { setup, assign, fromPromise } from 'xstate' ;
interface AuthContext {
user : { id : string ; email : string ; role : string } | null ;
accessToken : string | null ;
refreshToken : string | null ;
mfaRequired : boolean ;
mfaMethod : 'totp' | 'sms' | null ;
error : string | null ;
retryCount : number ;
}
const authMachine = setup ({
types: {
context: {} as AuthContext ,
events: {} as
| { type: 'LOGIN' ; email: string; password: string }
| { type: 'OAUTH_START' ; provider: 'google' | 'github' }
| { type: 'OAUTH_CALLBACK' ; code: string; state: string }
| { type: 'MFA_SUBMIT' ; code: string }
| { type: 'LOGOUT' }
| { type: 'TOKEN_EXPIRED' }
| { type: 'SESSION_CHECK' },
},
actors: {
loginWithCredentials: fromPromise (
async ({ input } : { input : { email : string ; password : string } }) => {
const res = await fetch ( '/api/auth/login' , {
method: 'POST' ,
headers: { 'Content-Type' : 'application/json' },
body: JSON . stringify (input),
});
if ( ! res.ok) throw new Error ( '認証に失敗しました' );
return res. json ();
}
),
verifyMfa: fromPromise (
async ({ input } : { input : { code : string ; token : string } }) => {
const res = await fetch ( '/api/auth/mfa/verify' , {
method: 'POST' ,
headers: { 'Content-Type' : 'application/json' },
body: JSON . stringify (input),
});
if ( ! res.ok) throw new Error ( 'MFA検証に失敗しました' );
return res. json ();
}
),
refreshAccessToken: fromPromise (
async ({ input } : { input : { refreshToken : string } }) => {
const res = await fetch ( '/api/auth/refresh' , {
method: 'POST' ,
headers: { 'Content-Type' : 'application/json' },
body: JSON . stringify (input),
});
if ( ! res.ok) throw new Error ( 'トークン更新に失敗しました' );
return res. json ();
}
),
},
guards: {
requiresMfa : ({ event }) => {
// loginWithCredentials の結果に mfaRequired フラグがある場合
return event.type === 'xstate.done.actor' && event.output?.mfaRequired;
},
canRetry : ({ context }) => context.retryCount < 3 ,
},
}). createMachine ({
id: 'auth' ,
initial: 'idle' ,
context: {
user: null ,
accessToken: null ,
refreshToken: null ,
mfaRequired: false ,
mfaMethod: null ,
error: null ,
retryCount: 0 ,
},
states: {
idle: {
on: {
LOGIN: 'authenticating' ,
OAUTH_START: 'oauthRedirect' ,
SESSION_CHECK: 'checkingSession' ,
},
},
checkingSession: {
invoke: {
src: 'refreshAccessToken' ,
input : ({ context }) => ({
refreshToken: context.refreshToken ?? '' ,
}),
onDone: {
target: 'authenticated' ,
actions: assign ({
accessToken : ({ event }) => event.output.accessToken,
user : ({ event }) => event.output.user,
}),
},
onError: 'idle' ,
},
},
authenticating: {
invoke: {
src: 'loginWithCredentials' ,
input : ({ event }) => ({
email: (event as any ).email,
password: (event as any ).password,
}),
onDone: [
{
guard: 'requiresMfa' ,
target: 'mfaChallenge' ,
actions: assign ({
mfaRequired: true ,
mfaMethod : ({ event }) => event.output.mfaMethod,
}),
},
{
target: 'authenticated' ,
actions: assign ({
user : ({ event }) => event.output.user,
accessToken : ({ event }) => event.output.accessToken,
refreshToken : ({ event }) => event.output.refreshToken,
error: null ,
}),
},
],
onError: {
target: 'authError' ,
actions: assign ({
error : ({ event }) => (event.error as Error ).message,
retryCount : ({ context }) => context.retryCount + 1 ,
}),
},
},
},
mfaChallenge: {
on: {
MFA_SUBMIT: 'verifyingMfa' ,
},
},
verifyingMfa: {
invoke: {
src: 'verifyMfa' ,
input : ({ event , context }) => ({
code: (event as any ).code,
token: context.accessToken ?? '' ,
}),
onDone: {
target: 'authenticated' ,
actions: assign ({
user : ({ event }) => event.output.user,
accessToken : ({ event }) => event.output.accessToken,
mfaRequired: false ,
}),
},
onError: {
target: 'mfaChallenge' ,
actions: assign ({
error: '確認コードが正しくありません。再度お試しください。' ,
}),
},
},
},
oauthRedirect: {
// OAuth プロバイダーへリダイレクト
entry : ({ event }) => {
if (event.type === 'OAUTH_START' ) {
window.location.href = `/api/auth/oauth/${ event . provider }` ;
}
},
on: {
OAUTH_CALLBACK: 'authenticating' ,
},
},
authenticated: {
on: {
LOGOUT: {
target: 'idle' ,
actions: assign ({
user: null ,
accessToken: null ,
refreshToken: null ,
error: null ,
}),
},
TOKEN_EXPIRED: 'refreshingToken' ,
},
},
refreshingToken: {
invoke: {
src: 'refreshAccessToken' ,
input : ({ context }) => ({
refreshToken: context.refreshToken ?? '' ,
}),
onDone: {
target: 'authenticated' ,
actions: assign ({
accessToken : ({ event }) => event.output.accessToken,
}),
},
onError: {
target: 'idle' ,
actions: assign ({
user: null ,
accessToken: null ,
refreshToken: null ,
error: 'セッションの有効期限が切れました。再ログインしてください。' ,
}),
},
},
},
authError: {
on: {
LOGIN: {
guard: 'canRetry' ,
target: 'authenticating' ,
},
},
},
},
});
このパターンのポイントは、MFA が必要な場合と不要な場合の分岐 をガード条件で宣言的に表現している点です。useState で同じロジックを書くと、isMfaRequired && isAuthenticated && !isLoading のような条件分岐が散在し、バグの温床になります。
パターン3: リアルタイム同期 — WebSocket 接続管理
WebSocket を使ったリアルタイム機能では、接続・切断・再接続・バックオフといった状態遷移が複雑になります。
import { setup, assign, fromCallback } from 'xstate' ;
interface WsContext {
socket : WebSocket | null ;
url : string ;
retryCount : number ;
maxRetries : number ;
messages : Array <{ id : string ; data : unknown ; timestamp : number }>;
pendingMessages : Array <{ data : unknown ; timestamp : number }>;
}
const websocketMachine = setup ({
types: {
context: {} as WsContext ,
events: {} as
| { type: 'CONNECT' }
| { type: 'DISCONNECT' }
| { type: 'SEND' ; data: unknown }
| { type: 'MESSAGE_RECEIVED' ; data: unknown }
| { type: 'CONNECTION_OPENED' ; socket: WebSocket }
| { type: 'CONNECTION_CLOSED' ; code: number }
| { type: 'CONNECTION_ERROR' ; error: Event },
},
actors: {
websocketConnection: fromCallback (({ sendBack , input }) => {
const ws = new WebSocket (input.url);
ws. onopen = () => sendBack ({
type: 'CONNECTION_OPENED' ,
socket: ws,
});
ws. onmessage = ( evt ) => sendBack ({
type: 'MESSAGE_RECEIVED' ,
data: JSON . parse (evt.data),
});
ws. onclose = ( evt ) => sendBack ({
type: 'CONNECTION_CLOSED' ,
code: evt.code,
});
ws. onerror = ( evt ) => sendBack ({
type: 'CONNECTION_ERROR' ,
error: evt,
});
return () => ws. close ();
}),
},
guards: {
canRetry : ({ context }) =>
context.retryCount < context.maxRetries,
isNormalClosure : ({ event }) =>
event.type === 'CONNECTION_CLOSED' && event.code === 1000 ,
},
}). createMachine ({
id: 'websocket' ,
initial: 'disconnected' ,
context: {
socket: null ,
url: 'wss://api.example.com/ws' ,
retryCount: 0 ,
maxRetries: 5 ,
messages: [],
pendingMessages: [],
},
states: {
disconnected: {
on: {
CONNECT: 'connecting' ,
},
},
connecting: {
invoke: {
src: 'websocketConnection' ,
input : ({ context }) => ({ url: context.url }),
},
on: {
CONNECTION_OPENED: {
target: 'connected' ,
actions: assign ({
socket : ({ event }) => event.socket,
retryCount: 0 ,
}),
},
CONNECTION_ERROR: [
{
guard: 'canRetry' ,
target: 'reconnecting' ,
},
{ target: 'failed' },
],
},
},
connected: {
on: {
SEND: {
actions : ({ context , event }) => {
context.socket?. send ( JSON . stringify (event.data));
},
},
MESSAGE_RECEIVED: {
actions: assign ({
messages : ({ context , event }) => [
... context.messages,
{
id: crypto. randomUUID (),
data: event.data,
timestamp: Date. now (),
},
],
}),
},
CONNECTION_CLOSED: [
{
guard: 'isNormalClosure' ,
target: 'disconnected' ,
},
{
guard: 'canRetry' ,
target: 'reconnecting' ,
},
{ target: 'failed' },
],
DISCONNECT: {
target: 'disconnected' ,
actions : ({ context }) => {
context.socket?. close ( 1000 , 'User disconnected' );
},
},
},
},
reconnecting: {
after: {
// 指数バックオフ: 1秒、2秒、4秒、8秒、16秒
RECONNECT_DELAY: 'connecting' ,
},
entry: assign ({
retryCount : ({ context }) => context.retryCount + 1 ,
}),
},
failed: {
on: {
CONNECT: {
target: 'connecting' ,
actions: assign ({ retryCount: 0 }),
},
},
},
},
// 遅延の動的計算
delays: {
RECONNECT_DELAY : ({ context }) =>
Math. min ( 1000 * Math. pow ( 2 , context.retryCount), 30000 ),
},
});
指数バックオフによる再接続を after と動的遅延で表現しています。従来の setTimeout + フラグ管理に比べて、「何回目の再接続でどれだけ待つか」が定義の1箇所に集まります。デバッグのときに読む場所が減る、というのが実際の利点です。
パターン4: Actor Model による並行処理
XState v5 の真の力は Actor Model にあります。親マシンから子Actorをスポーン(生成)し、独立した状態管理を並行して実行できます。
import { setup, assign, sendTo, fromPromise } from 'xstate' ;
// 個別のファイルアップロードを管理する子 Actor
const fileUploadMachine = setup ({
types: {
context: {} as {
file : File ;
progress : number ;
url : string | null ;
error : string | null ;
},
events: {} as
| { type: 'START' }
| { type: 'PROGRESS' ; value: number }
| { type: 'CANCEL' },
input: {} as { file : File },
},
actors: {
uploadFile: fromPromise ( async ({ input , self }) => {
const formData = new FormData ();
formData. append ( 'file' , input.file);
const xhr = new XMLHttpRequest ();
return new Promise (( resolve , reject ) => {
xhr.upload. addEventListener ( 'progress' , ( e ) => {
if (e.lengthComputable) {
self. send ({
type: 'PROGRESS' ,
value: Math. round ((e.loaded / e.total) * 100 ),
});
}
});
xhr. addEventListener ( 'load' , () => {
if (xhr.status === 200 ) {
resolve ( JSON . parse (xhr.responseText));
} else {
reject ( new Error ( `Upload failed: ${ xhr . status }` ));
}
});
xhr. addEventListener ( 'error' , () => reject ( new Error ( 'Network error' )));
xhr. open ( 'POST' , '/api/upload' );
xhr. send (formData);
});
}),
},
}). createMachine ({
id: 'fileUpload' ,
initial: 'idle' ,
context : ({ input }) => ({
file: input.file,
progress: 0 ,
url: null ,
error: null ,
}),
states: {
idle: { on: { START: 'uploading' } },
uploading: {
invoke: {
src: 'uploadFile' ,
input : ({ context }) => ({ file: context.file }),
onDone: {
target: 'complete' ,
actions: assign ({
url : ({ event }) => event.output.url,
progress: 100 ,
}),
},
onError: {
target: 'error' ,
actions: assign ({
error : ({ event }) => (event.error as Error ).message,
}),
},
},
on: {
PROGRESS: {
actions: assign ({
progress : ({ event }) => event.value,
}),
},
CANCEL: 'cancelled' ,
},
},
complete: { type: 'final' },
error: {
on: { START: 'uploading' },
},
cancelled: { type: 'final' },
},
});
// 親マシン: 複数ファイルのアップロードを管理
const batchUploadMachine = setup ({
types: {
context: {} as {
uploads : Map < string , any >; // ActorRef のマップ
completedCount : number ;
totalCount : number ;
},
events: {} as
| { type: 'ADD_FILES' ; files: File[] }
| { type: 'START_ALL' }
| { type: 'FILE_COMPLETE' ; fileId: string },
},
}). createMachine ({
id: 'batchUpload' ,
initial: 'idle' ,
context: {
uploads: new Map (),
completedCount: 0 ,
totalCount: 0 ,
},
states: {
idle: {
on: {
ADD_FILES: {
target: 'ready' ,
actions: assign ({
totalCount : ({ event }) => event.files. length ,
// 各ファイルの子 Actor をスポーン
}),
},
},
},
ready: {
on: {
START_ALL: 'uploading' ,
},
},
uploading: {
// 全ての子 Actor が完了したら done 状態へ
always: {
guard : ({ context }) =>
context.completedCount >= context.totalCount,
target: 'done' ,
},
on: {
FILE_COMPLETE: {
actions: assign ({
completedCount : ({ context }) => context.completedCount + 1 ,
}),
},
},
},
done: { type: 'final' },
},
});
パターン5: Inspect API による状態の可視化とデバッグ
XState v5 の Inspect API は、本番環境でもステートマシンの動作をリアルタイムに監視できる強力なツールです。
import { createActor } from 'xstate' ;
import { createBrowserInspector } from '@statelyai/inspect' ;
// 開発環境でのみ Inspector を有効化
const inspector = process.env. NODE_ENV === 'development'
? createBrowserInspector ()
: undefined ;
const actor = createActor (authMachine, {
inspect: inspector?.inspect,
});
// カスタム Inspect ハンドラ(本番向けテレメトリ)
const productionInspector = ( inspectionEvent : any ) => {
if (inspectionEvent.type === '@xstate.snapshot' ) {
// 状態遷移のログを送信
analytics. track ( 'state_transition' , {
machineId: inspectionEvent.actorRef.id,
state: inspectionEvent.snapshot.value,
timestamp: Date. now (),
});
}
if (inspectionEvent.type === '@xstate.event' ) {
// エラーイベントの監視
if (inspectionEvent.event.type. includes ( 'error' )) {
errorTracking. capture ({
machineId: inspectionEvent.actorRef.id,
event: inspectionEvent.event,
});
}
}
};
const productionActor = createActor (authMachine, {
inspect: productionInspector,
});
Antigravity でデバッグする際は、Stately Inspector を Chrome DevTools と連携させることで、AIエージェントが提案したステートマシンの動作をリアルタイムに検証できます。
XState v5 × Antigravity のテスト戦略
ステートマシンの最大の利点の一つは、モデルベーステスト が可能なことです。状態遷移グラフから自動でテストパスを生成できます。
import { createActor } from 'xstate' ;
import { describe, it, expect } from 'vitest' ;
describe ( '認証フロー' , () => {
it ( '正常ログイン → 認証済み' , async () => {
const actor = createActor (authMachine);
actor. start ();
expect (actor. getSnapshot ().value). toBe ( 'idle' );
actor. send ({
type: 'LOGIN' ,
email: 'user@example.com' ,
password: 'secure-password' ,
});
expect (actor. getSnapshot ().value). toBe ( 'authenticating' );
// invoke の完了を待機
await waitFor (actor, ( state ) => state.value === 'authenticated' );
expect (actor. getSnapshot ().context.user).not. toBeNull ();
});
it ( 'MFA が必要な場合 → MFA チャレンジ' , async () => {
const actor = createActor (authMachine);
actor. start ();
actor. send ({
type: 'LOGIN' ,
email: 'admin@example.com' ,
password: 'secure-password' ,
});
await waitFor (actor, ( state ) => state.value === 'mfaChallenge' );
expect (actor. getSnapshot ().context.mfaRequired). toBe ( true );
actor. send ({ type: 'MFA_SUBMIT' , code: '123456' });
await waitFor (actor, ( state ) => state.value === 'authenticated' );
});
it ( 'リトライ上限超過 → authError で停止' , async () => {
const actor = createActor (authMachine, {
// 常に失敗するモック
actors: {
loginWithCredentials: fromPromise ( async () => {
throw new Error ( 'Server error' );
}),
},
});
actor. start ();
// 3回失敗
for ( let i = 0 ; i < 3 ; i ++ ) {
actor. send ({ type: 'LOGIN' , email: 'a@b.com' , password: 'x' });
await waitFor (actor, ( state ) => state.value === 'authError' );
}
// 4回目はガードで拒否
actor. send ({ type: 'LOGIN' , email: 'a@b.com' , password: 'x' });
expect (actor. getSnapshot ().value). toBe ( 'authError' );
});
});
テスト駆動開発の基本戦略についてはAntigravity × TDD実践マスター も参考にしてください。Antigravity の AI エージェントに「このステートマシンの全遷移パスをカバーするテストを生成して」と指示すると、到達可能な状態遷移を網羅するテストコードが生成されます。@xstate/test パッケージの createTestModel を使えば、状態グラフからテストパスを自動列挙することも可能です。
スナップショットを保存したあとにマシン定義を変えるとき
ここまでのパターンは、どれもページを開いている間だけの話でした。実務では「入力途中のフォームをリロード後も残したい」「決済フローの続きから再開したい」という要望が必ず来ます。XState v5 には actor.getPersistedSnapshot() があり、アクターの状態を丸ごと直列化して createActor(machine, { snapshot }) で戻せます。
ここに、私が実際に踏んだ落とし穴があります。保存されたスナップショットは、保存した時点のマシン定義を前提にした値 です。あとから状態名を変えたり、ステップを1つ減らしたりすると、復元されたアクターは存在しない状態を指したまま起動します。
confirming を reviewing にリネームしたデプロイの翌日、「フォームが白いまま何も起きない」という問い合わせが数件届きました。厄介なのは、例外が出ないことです。存在しない状態で静かに止まっているだけなので、Sentry には何も上がってきません。前日から続きを再開しようとしたユーザーだけが踏む不具合でした。
個人開発だと問い合わせの一次受けも自分なので、この手の「エラーにならない不具合」は文面から原因を推測することになります。「白い画面」という3文字から localStorage を疑えるようになるまで、丸一日かかりました。
対処は難しくありません。スナップショットにマシンのバージョンを添えて保存し、一致しなければ捨てます。
import { createActor, type Actor } from 'xstate' ;
import { checkoutMachine } from './checkoutMachine' ;
// マシン定義に手を入れたら必ず上げる。上げ忘れが唯一の運用リスク
const MACHINE_VERSION = 3 ;
const STORAGE_KEY = 'checkout-machine' ;
const MAX_AGE_MS = 1000 * 60 * 60 * 24 ; // 24時間で期限切れ
type StoredSnapshot = {
version : number ;
savedAt : number ;
snapshot : unknown ;
};
// 復元しても安全な状態だけを列挙する。
// invoke 実行中の状態(submitting / verifying など)は絶対に含めない
const RESTORABLE = new Set ([ 'personalInfo' , 'addressInfo' , 'confirming' ]);
export function persist ( actor : Actor < typeof checkoutMachine>) {
const snapshot = actor. getSnapshot ();
if ( ! RESTORABLE . has ( String (snapshot.value))) {
localStorage. removeItem ( STORAGE_KEY );
return ;
}
const payload : StoredSnapshot = {
version: MACHINE_VERSION ,
savedAt: Date. now (),
snapshot: actor. getPersistedSnapshot (),
};
localStorage. setItem ( STORAGE_KEY , JSON . stringify (payload));
}
export function restore () : unknown | undefined {
const raw = localStorage. getItem ( STORAGE_KEY );
if ( ! raw) return undefined ;
let stored : StoredSnapshot ;
try {
stored = JSON . parse (raw) as StoredSnapshot ;
} catch {
localStorage. removeItem ( STORAGE_KEY );
return undefined ;
}
const expired = Date. now () - stored.savedAt > MAX_AGE_MS ;
if (stored.version !== MACHINE_VERSION || expired) {
// 移行を頑張らない。捨てて初期状態から始めるほうが事故が少ない
localStorage. removeItem ( STORAGE_KEY );
return undefined ;
}
return stored.snapshot;
}
// 起動時
const actor = createActor (checkoutMachine, { snapshot: restore () });
actor. subscribe (() => persist (actor));
actor. start ();
RESTORABLE で通信中の状態を除外しているのが、この実装で一番効いている部分です。getPersistedSnapshot() は「その状態にいた」という事実は保存しますが、実行中だった Promise までは保存しません。submitting の状態を保存して復元すると、決済リクエストが飛んでいないのに submitting のまま固まったアクターができあがります。ユーザーから見れば「ぐるぐる回ったまま進まない」画面です。
さらに悪いのは、ここで復元をリトライ可能にしてしまった場合です。実際には成功していた決済に対してもう一度リクエストが飛ぶ余地が生まれます。「通信中は保存しない」は、UXの話ではなく二重課金の予防策として決めています。
変更の種類ごとに、古いスナップショットがどう振る舞うかを整理しておきます。
マシン定義への変更 古いスナップショットの復元 取るべき対処
状態の追加(既存の名前はそのまま) おおむね通る そのままでも動くが、上げておくと安全
状態名のリネーム・削除 例外を出さずに固まる バージョンを上げて破棄する
context のフィールド追加 undefined のまま復元される復元直後に既定値で埋めるか、バージョンを上げる
ガード条件の変更 通るが挙動だけ変わる バージョンを上げる(一番見つけにくい)
invoke 実行中の状態を保存その状態で停止する 保存対象から外す
表の4行目、ガード条件の変更が実務では最もやっかいです。復元は成功し、画面も出て、ボタンも押せる。ただし通る分岐だけが以前と違う。テストでも気づきにくいので、私は「マシンのファイルを触ったらバージョンを上げる」を機械的なルールにしました。判断が要らないルールのほうが守れます。
Antigravity で作業するときは、この規約を .antigravity/rules.md に一行だけ書いておくと効きます。「checkoutMachine.ts を編集したら MACHINE_VERSION も更新する」と書いておけば、マシン定義に手を入れた差分で AI 側が更新を提案してくれます。人間が忘れる種類のルールなので、ここは任せてよい領域だと考えています。
個人開発で判断が変わったところ
導入した直後は、ほとんどの画面をマシンにしました。結果として、開閉するだけのモーダルにまで createMachine を書くことになり、コード量だけが増えました。
いま自分に課している基準はひとつです。状態が3つ以上あり、かつ遷移の順序に意味があるか 。この条件を満たさないなら useState のほうが読みやすいと考えています。モーダルの開閉には順序の意味がありません。マルチステップフォームにはあります。この線引きにしてから、マシンの数は減り、残ったマシンの価値は上がりました。
再レンダリングの扱いも途中で変えました。useMachine は素直ですが、context のどこか一箇所でも変われば購読側が再レンダリングされます。入力のたびに context を更新するフォームでは、これが体感速度に効いてきます。
// 素直だが、context のどこが変わっても再レンダリングされる
const [ state , send ] = useMachine (checkoutMachine);
// 見ている値が変わったときだけ再レンダリングされる
const actorRef = CheckoutContext. useActorRef ();
const step = useSelector (actorRef, ( s ) => s.value);
const canSubmit = useSelector (actorRef, ( s ) =>
s. can ({ type: 'SUBMIT_PERSONAL' })
);
s.can() は、ボタンの活性判定をコンポーネント側で書き直さずに済むという点で気に入っています。「押せるかどうか」の判断はガード条件に一度書いてあるので、UI 側はそれを問い合わせるだけです。判定ロジックが2箇所に散らない構造は、あとから条件が増えたときに効いてきます。
AI エージェントに任せる範囲についても、使っているうちに線引きが変わりました。遷移図の叩き台をつくらせる、状態を1つ足したときのテストを書かせる——このあたりは速く、そのまま使えることが多いです。一方でガード条件の中身、つまり「どういうときに次へ進んでよいのか」というビジネスルールは、結局自分で書き直しています。仕様が私の頭の中にしかないので、当然といえば当然でした。
Inspect API も、最初は全イベントを送っていました。開発中は快適でしたが、本番に出したところ操作1回あたり十数件のイベントが飛び、分析基盤側のコストのほうが目立つようになりました。いまは @xstate.event のうち失敗系だけを送り、成功パスは1%サンプリングにしています。私自身、最初は全部見えていないと落ち着かなかったのですが、あとから見返すのは結局いつも失敗した側だけでした。
ステートマシンは、書き始めた日より、3か月後に仕様変更が来た日に効いてくる道具だと感じています。真偽値の組み合わせを数えながら怯えるかわりに、遷移図の1本の矢印を足すかどうかだけを考えればよくなりました。
まとめ
最初の一歩は、新しいマシンを書くことではないと思っています。いま抱えている画面のうち、真偽値が3つ以上あって、その組み合わせを口で説明できないものを1つだけ選ぶ。そこから紙に状態名を書き出すほうが、ライブラリの API を覚えるより先に効きます。
移し替える順番としては、認証フローかマルチステップフォームが向いています。状態の境界が業務的にはっきりしていて、遷移の正解を人に確認できるからです。逆に、境界が曖昧なダッシュボードのようなものから始めると、マシン定義そのものが揺れて手戻りになります。
移したあとは、MACHINE_VERSION の運用だけ先に決めておいてください。スナップショットの永続化は後から足したくなる機能で、そのときには既に本番のブラウザに古い状態が残っています。同じ考え方をサーバー側に持ち込むと、冪等性とリトライの分類という別の論点が出てきます。そちらはTemporal の耐障害性ワークフローを本番で信用するために に実装メモとして残しました。
長い記事にお付き合いいただき、ありがとうございました。状態の数え方が少しでも楽になれば嬉しく思います。