はじめに
XState v5 において、Context(状態データ)として「配列」を直接持たせた場合に発生する予期せぬ挙動についてまとめました。
結論
XState v5 では、Context に配列を assign してもオブジェクトとして値が入ってしまうようです。
そのため、予期せぬ挙動を生まないようにオブジェクトでラップしたほうが無難なようです。
❌ NG: context: [] as string[]
✅ OK: context: { items: [] } as { items: string[] }
検証したこと
Context が配列である State Machine を作成し、assign で要素を追加したときに型がどうなるかを検証しました。
検証用マシン (machine.ts)
assign 関数の中では、確実に「配列」を返していることをログで確認しています。
import { setup, assign } from "xstate";
export const arrayMachine = setup({
types: {
context: [] as string[], //初期値も配列
events: {} as { type: "ADD"; item: string },
},
}).createMachine({
id: "arrayMachine",
initial: "active",
context: [],
states: {
active: {
on: {
ADD: {
actions: assign(({ context, event }) => {
// assignの最初の段階は配列になっている
console.log(
"DEBUG: assign initial context:",
context,
"Is Array?",
Array.isArray(context)
);
const newValue = [...context, event.item];
// ここでは確実に配列になっている
console.log(
"DEBUG: assign returning:",
newValue,
"Is Array?",
Array.isArray(newValue)
);
return newValue;
}),
},
},
},
},
});
検証スクリプト
初期状態と、イベント送信後の Context の型をチェックします。
import { createActor } from "xstate";
import { arrayMachine } from "./arrayMachine";
const actor = createActor(arrayMachine);
actor.start();
console.log("--- Initial State ---");
console.log("Is Array?", Array.isArray(actor.getSnapshot().context));
console.log("\n--- Sending ADD event ---");
actor.send({ type: "ADD", item: "first-item" });
const updatedContext = actor.getSnapshot().context;
console.log("Context value:", updatedContext);
console.log("Is Array?", Array.isArray(updatedContext));
console.log("Constructor:", updatedContext.constructor.name);
実行結果
assign が配列を返しているにもかかわらず、更新後の Context は オブジェクト に変換されてしまいました。
--- Initial State ---
Context value: []
Is Array? true
--- Sending ADD event ---
DEBUG: assign initial context: [] Is Array? true
DEBUG: assign returning: [ 'first-item' ] Is Array? true
Context value: { '0': 'first-item' }
Is Array? false
Constructor: Object
初期状態では [] (Array) でしたが、 assign が走ると { '0': 'first-item' } というインデックスをキーに持つオブジェクトに変化しています。これにより、map や filter などの配列メソッドが使えなくなり、ランタイムエラーの原因になります。
原因
XState v5 の assign アクションの実装において、強制的にオブジェクトとしてマージ(Shallow Merge)される処理 が存在するためです。
GitHub 上のソースコード(packages/core/src/actions/assign.ts)を確認すると、以下のような実装になっています。
// assign.ts (抜粋)
const updatedContext = Object.assign({}, snapshot.context, partialUpdate);
- 第一引数に 空のオブジェクト
{}、第二引数にsnapshot.context(現在のコンテキスト)が渡されます。 - もし
snapshot.contextが配列['a', 'b']であったとしても、Object.assign({}, ['a', 'b'])が実行されます。 - JavaScript の仕様上、これは
{ '0': 'a', '1': 'b' }という オブジェクト を生成します。
→ ユーザー側でどれだけ気をつけて「配列」を返すように実装しても、ライブラリの内部実装で強制的にオブジェクトへ変換されてしまう のが XState v5 の仕様のようです。
対策
Context は必ず オブジェクト にし、配列はそのプロパティとして持たせるのが吉です。
const listMachine = setup({
types: {
// 配列をプロパティとして持つ
context: {} as { items: string[] },
},
}).createMachine({
context: { items: [] },
on: {
ADD: {
actions: assign(({ context, event }) => ({
// これなら items プロパティだけが更新される
items: [...context.items, event.item],
})),
},
},
});
これなら context.items は常に配列のまま保たれます。
XState でリストを扱う際は、横着せずにオブジェクトでラップしましょう。
追記
本件について、XState 公式リポジトリの GitHub Discussions にて質問を行ったところ、メンテナーの方から回答をいただきました。
回答の要約:
-
XState v5 において、Context は常にオブジェクトであることを想定している
→assign アクションによって配列がオブジェクトに変換されてしまう挙動は、バグではなく仕様
→PR を出して公式のドキュメントに明記していただきました。
https://stately.ai/docs/context
