はじめに
状態管理、正直なところ苦手意識があった。
useStateを何個も並べて、フラグ変数が増えていって、「あれ、このボタンってどの状態のときに押せるんだっけ?」みたいなバグを何度踏んだことか。個人的には、この手の問題に30代になってようやく向き合えた気がする。
そこで出会ったのがXStateというライブラリ。GitHubスター数29,000超え、月間ダウンロード数1,000万以上という実績のあるやつだ。
読むより触った方が早いと思う。XStateのcreateMachineとcreateActorで作った状態マシンをボタンで動かせるサンプルを用意したので、先に挙動を見たい方はこちらからどうぞ。
XStateとは
XStateは、JavaScriptとTypeScript向けのアクターベースの状態管理・オーケストレーションライブラリ。
要するに、「アプリの状態をステートマシン(状態機械)として定義しよう」という考え方を実装したもの。これ、意外とシンプルな発想なんだけど、効果は絶大だった。
依存関係ゼロ、バンドルサイズ約14KB(gzip圧縮後)という軽量さも魅力的なポイント。
特徴・メリット
状態遷移が明確になる
従来のやり方だと、こんなコードになりがち:
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [data, setData] = useState(null);
// 各フラグを適切なタイミングで更新...
// でも、isLoadingとisErrorが同時にtrueになる可能性は?
XStateを使うと、「ローディング中」「エラー」「成功」が同時に存在できないことが構造的に保証される。
視覚化ツールが便利
Stately Studioという公式のビジュアルエディタがある。状態遷移図をGUIで作成・編集できるし、既存のコードを可視化することもできる。チーム開発でのコミュニケーションツールとしても使える。
主要フレームワーク対応
@xstate/react- React用フック@xstate/vue- Vue用コンポジション関数@xstate/svelte- Svelte用ユーティリティ@xstate/solid- Solid.js用
個人的にはReactで使っているけど、導入のハードルは低い。
その他の特徴
- 階層的ステート: 入れ子の状態を表現できる
- 並列ステート: 同時に複数の状態を持てる
- 履歴ステート: 前の状態を記憶できる
- TypeScript完全対応: 型安全に書ける
- SCXML仕様準拠: 業界標準に則っている
インストール方法
# npm
npm install xstate
# pnpm
pnpm install xstate
# yarn
yarn add xstate
Reactで使う場合は追加でフックも入れておく:
npm install @xstate/react
XStateのサンプルを動かす
createMachineで状態と遷移を定義し、createActorでそれを実際に動かす「アクター」を起動する。これがXStateの基本の流れ。下のサンプルは「接続」ボタンを押すとidle→connecting→(800ms後に自動で)connectedと状態が進み、「切断」ボタンでidleに戻る通信状態マシンだ。connecting中に「切断」を押しても何も起きない点に注目してほしい。connectingのステートにはDISCONNECTのハンドラを定義していないので、構造的に無視される。
要点だけ抜き出すと、こういう定義になっている。
const connectionMachine = createMachine({
id: 'connection',
initial: 'idle',
states: {
idle: { on: { CONNECT: 'connecting' } },
connecting: {
// 800ms後に自動でconnectedへ遷移(afterによる遅延遷移)
after: { 800: { target: 'connected' } }
},
connected: { on: { DISCONNECT: 'idle' } }
}
})
const actor = createActor(connectionMachine)
actor.subscribe((state) => console.log(state.value))
actor.start()
actor.send({ type: 'CONNECT' }) // idle → connecting → (800ms後) connected
実際に動かせるのが下のサンプルだ。ボタンを押してstate.valueがどう変わるか、そしてconnecting中に「切断」が効かないことを確かめてみてほしい。
connectingのブロックにon: { DISCONNECT: 'idle' }を追加すれば、接続中でも切断できるようになる。逆に言うと、追加しない限りは「接続中に切断」という遷移はXStateのレベルで存在しないことになる。useStateのフラグ管理だと「if文の書き忘れ」で起きがちなバグが、ステートマシンでは「定義していないので起こらない」に変わる。これがXStateの一番の強みだと感じている。
基本的な使い方
シンプルなトグルの例
まずは一番シンプルな例から。
import { createMachine, createActor } from 'xstate';
const toggleMachine = createMachine({
id: 'toggle',
initial: 'inactive',
states: {
inactive: {
on: { TOGGLE: { target: 'active' } }
},
active: {
on: { TOGGLE: { target: 'inactive' } }
}
}
});
// アクターを作成して起動
const toggleActor = createActor(toggleMachine);
toggleActor.subscribe((state) => console.log(state.value));
toggleActor.start();
// イベントを送信
toggleActor.send({ type: 'TOGGLE' }); // 'active'
toggleActor.send({ type: 'TOGGLE' }); // 'inactive'
コンテキスト(データ)を持つ例
カウンターのように、状態と一緒にデータを管理したい場合:
import { createMachine, assign, createActor } from 'xstate';
const countMachine = createMachine({
id: 'counter',
context: { count: 0 },
on: {
INC: {
actions: assign({
count: ({ context }) => context.count + 1
})
},
DEC: {
actions: assign({
count: ({ context }) => context.count - 1
})
},
RESET: {
actions: assign({ count: 0 })
}
}
});
const countActor = createActor(countMachine).start();
countActor.subscribe((state) => {
console.log('現在のカウント:', state.context.count);
});
countActor.send({ type: 'INC' }); // 1
countActor.send({ type: 'INC' }); // 2
countActor.send({ type: 'DEC' }); // 1
Reactでの使用例
import { useMachine } from '@xstate/react';
import { createMachine, assign } from 'xstate';
const toggleMachine = createMachine({
id: 'toggle',
initial: 'inactive',
context: { count: 0 },
states: {
inactive: {
on: { TOGGLE: { target: 'active' } }
},
active: {
entry: assign({
count: ({ context }) => context.count + 1
}),
on: { TOGGLE: { target: 'inactive' } }
}
}
});
function Toggle() {
const [state, send] = useMachine(toggleMachine);
return (
<div>
<p>状態: {state.value}</p>
<p>切り替え回数: {state.context.count}</p>
<button onClick={() => send({ type: 'TOGGLE' })}>
トグル
</button>
</div>
);
}
実践的なユースケース
ガード条件付きの遷移(在庫チェック)
「条件を満たしたときだけ遷移させたい」というケースは実務でよくある。XStateではonの遷移先を配列にして、それぞれにguard(真偽値を返す関数)を付けることで、条件によって遷移先を出し分けられる。ここでは在庫が残っているときだけ注文(ORDER)を受け付け、在庫切れならsoldOutステートに落とす例を見てみる。
const stockMachine = createMachine({
id: 'stock',
initial: 'available',
context: { stock: 2 },
states: {
available: {
on: {
ORDER: [
{
guard: ({ context }) => context.stock > 0,
actions: assign({ stock: ({ context }) => context.stock - 1 })
},
{ target: 'soldOut' } // guardを満たさない場合はこちら
]
}
},
soldOut: {
on: { RESTOCK: { target: 'available', actions: assign({ stock: 3 }) } }
}
}
})
実際に注文ボタンを連打して在庫が0になる瞬間を確かめてみてほしい。
context: { stock: 2 }を{ stock: 0 }に変えれば、開いた瞬間からsoldOutに落ちる挙動も試せる。guardは単なる関数なので、context.stock > 0の部分をcontext.stock >= 2のように変えれば「2個以上残っているときだけ注文可能」といったルールにもすぐ差し替えられる。
コンテキストを使ったデータ保持(カート合計)
ステートマシンは「今どの状態か」だけでなく、contextに任意のデータを持たせられる。ここではショッピングカートを例に、assignアクションでcontextのitemsとtotalを更新しながら合計金額を保持するパターンを見てみる。
const cartMachine = createMachine({
id: 'cart',
initial: 'active',
context: { items: [], total: 0 },
states: {
active: {
on: {
ADD_ITEM: {
actions: assign({
items: ({ context, event }) => [...context.items, event.price],
total: ({ context, event }) => context.total + event.price
})
},
CLEAR: { actions: assign({ items: [], total: 0 }) }
}
}
}
})
商品を追加するたびにcontext.totalが加算されていく様子を、下のサンプルで確認できる。
ADD_ITEMイベントに載せるpriceの値を変えれば、任意の金額の商品を追加する処理に応用できる。contextはイミュータブルに扱うのがポイントで、assignの中では常に新しい配列・オブジェクトを返している(context.items.push(...)のような破壊的な操作はしない)。
階層ステートと並列ステート(動画プレイヤー)
XStateには、状態の中にさらに状態をネストできる階層ステートと、複数の状態系統を同時に独立して持てる並列ステートという仕組みがある。特に並列ステートは、useStateを並べるやり方だと表現しづらい「再生中かどうか」と「ミュート中かどうか」のような互いに関係ない状態の組み合わせを扱うのに向いている。動画プレイヤーを例に、type: 'parallel'で2系統の状態を独立に切り替えてみる。
const playerMachine = createMachine({
id: 'player',
type: 'parallel',
states: {
playback: {
initial: 'paused',
states: {
paused: { on: { TOGGLE_PLAY: 'playing' } },
playing: { on: { TOGGLE_PLAY: 'paused' } }
}
},
volume: {
initial: 'unmuted',
states: {
unmuted: { on: { TOGGLE_MUTE: 'muted' } },
muted: { on: { TOGGLE_MUTE: 'unmuted' } }
}
}
}
})
// state.value は { playback: 'playing', volume: 'muted' } のように両方同時に持てる
「再生」ボタンと「ミュート」ボタンをそれぞれ押して、state.valueが独立に変化する様子を確認してほしい。
playbackとvolumeが別々のオブジェクトキーとして同時に表示される点に注目してほしい。もし階層ステートにしたい場合は、たとえばplayingの中にさらにinitialとstatesを持たせて「早送り中」「通常再生中」のようなサブ状態を追加すれば、親のplayingを保ったまま子の状態だけを切り替えられる。
フォームのバリデーション状態管理
フォームって状態が複雑になりがちなんだよな。XStateで整理するとこうなる:
import { createMachine, assign } from 'xstate';
const formMachine = createMachine({
id: 'form',
initial: 'editing',
context: {
values: { email: '', password: '' },
errors: {}
},
states: {
editing: {
on: {
CHANGE: {
actions: assign({
values: ({ context, event }) => ({
...context.values,
[event.field]: event.value
})
})
},
SUBMIT: { target: 'validating' }
}
},
validating: {
always: [
{ target: 'submitting', guard: 'isValid' },
{ target: 'editing' }
]
},
submitting: {
invoke: {
src: 'submitForm',
onDone: { target: 'success' },
onError: { target: 'error' }
}
},
success: {
type: 'final'
},
error: {
on: { RETRY: { target: 'submitting' } }
}
}
});
これで「送信中に二重クリック」みたいなバグが構造的に防げる。
API呼び出しの状態管理
データフェッチのパターンもきれいに書ける:
const fetchMachine = createMachine({
id: 'fetch',
initial: 'idle',
context: {
data: null,
error: null
},
states: {
idle: {
on: { FETCH: { target: 'loading' } }
},
loading: {
invoke: {
src: 'fetchData',
onDone: {
target: 'success',
actions: assign({ data: ({ event }) => event.output })
},
onError: {
target: 'failure',
actions: assign({ error: ({ event }) => event.error })
}
}
},
success: {
on: { REFRESH: { target: 'loading' } }
},
failure: {
on: { RETRY: { target: 'loading' } }
}
}
});
「ローディング中はローディング中」「エラーはエラー」「成功は成功」という状態が明確になる。コードレビューでも「この状態のときにこのボタン押したらどうなるの?」みたいな質問がなくなった。
まとめ
XStateを使い始めて感じたのは、状態管理の問題が「設計の問題」になるということ。
今までは「フラグが増えてきたな、どうしよう」という実装の問題だったのが、「この状態からこの状態への遷移は必要か?」という設計の問題として考えられるようになった。
正直、学習コストはそれなりにある。ステートマシンの概念に馴染みがないと最初はとっつきにくいかもしれない。でも、一度理解すると「なんで今までこれを使わなかったんだ」と思えるレベル。
特に以下のような場面では導入を検討する価値があると思う:
- 複雑なUIの状態管理(モーダル、フォーム、ウィザード)
- 非同期処理のフロー制御
- ユーザーインタラクションの多いアプリ
個人的には、新規プロジェクトで複雑な状態管理が予想される場合は積極的に採用していきたいライブラリ。