はじめに
状態管理、正直なところ苦手意識があった。
useStateを何個も並べて、フラグ変数が増えていって、「あれ、このボタンってどの状態のときに押せるんだっけ?」みたいなバグを何度踏んだことか。個人的には、この手の問題に30代になってようやく向き合えた気がする。
そこで出会ったのがXStateというライブラリ。GitHubスター数29,000超え、月間ダウンロード数1,000万以上という実績のあるやつだ。
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
基本的な使い方
シンプルなトグルの例
まずは一番シンプルな例から。
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で整理するとこうなる:
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の状態管理(モーダル、フォーム、ウィザード)
- 非同期処理のフロー制御
- ユーザーインタラクションの多いアプリ
個人的には、新規プロジェクトで複雑な状態管理が予想される場合は積極的に採用していきたいライブラリ。