ステートマシンでバグを減らすアプリの作り方
みなさんステートマシンってご存知ですか?
日本語で状態遷移図ていうみたいですね。
上のリンクにも書いてあるとおり、「状態」を管理していつどのように状態が移り変わるかを体型的に表したやつです。
問題
そんなん言われてもピンとこないと思うので、例えば以下のような Todo アプリがあるとしましょう。(この記事では React を使ってますが、概念自体はどのフレームワークにも当てはまります)
function Todo() {
const [todos, setTodos] = React.useState(null);
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState(false);
const fetchTodos = async () => {
try {
setLoading(true);
const res = await fetch("some/api/todos");
const todos = await res.json();
setTodos(todos);
} catch (e) {
setError(true);
} finally {
setLoading(false);
}
};
if (isLoading) return "Loading...";
if (todos) {
return (
<div>
{todos.length > 0 ? (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
) : (
<div>No todos</div>
)}
<button onClick={fetchTodos} disabled={isLoading}>
Fetch todos
</button>
</div>
);
}
if (error) return "Something went wrong.";
}
見た目は普通の Todo アプリですね。ですが、このフローのままだとある一定の条件が発生した場合にバグが起こりうる可能性があります。分かりますか?
例えば、最初のリクエストがなんらかの理由で失敗し、リクエストエラーになるとします。その場合、このコンポーネント内のステートは以下のようになるはずです。
todos = [];
isLoading = false;
error = true;
ここでもう一回リクエストを飛ばし、成功するとします。その場合のステートはこうなります。
todos = [{...todos}, ...]
isLoading = false;
error = true;
もう分かりますね。todos
はちゃんとあるのに、error
はまだ true
のままです。
ここでよくある対処法としては、再リクエスト時に error
が true
の場合は一旦 false
にリセットするみたいなパターン。
const fetchTodos = async () => {
try {
setLoading(true);
if (error) {
setError(false);
}
const res = await fetch("some/api/todos");
const todos = await res.json();
setTodos(todos);
} catch (e) {
setError(true);
} finally {
setLoading(false);
}
};
これでもまだバグがありますね。なぜかというと、エラー時に todos
をリセットしていないからです。
if (error) return "Something went wrong.";
の部分を
if (todos) { ... }
の上にもってこれば解決なんですが、、まあ言いたいこと分かりますよね。ステートが至るところにあるとその管理が非常に複雑になります。数学の世界でいう combinatorial explosion (日本語で組合せ最適化問題)という事象ですね。簡単な Todo アプリでもこんなに気にすることが多いのに、機能が増え続ける大規模アプリとかだったらデバッグに一生かかったりするのも珍しくありません。
解決策
この問題を解決しようとしているのがステートマシンです。
ステートマシン自体は何年も前からある概念で、最近その技術がフロントエンドにも浸透してきました。
簡単にいうと、単一的なステートで管理するのではなく、物事(このコンテキスト上ではコンポーネント)を状態として捉えて、その数ある状態の中でだけ存在するステートやアクションを定義して体系化したもののことです。
分かりにくいですよね。ごめんなさい。先程の todo アプリをリファクタして見てみましょう。
type State =
| { state: "IDLE" }
| { state: "FETCHING" }
| { state: "FETCHED", todos: Todo[] }
| { state: "ERROR", error: string };
type Action =
| {
type: "FETCH",
}
| {
type: "FETCH_SUCCESS",
todos: Todo[],
}
| {
type: "FETCH_FAILURE",
error: string,
};
const reducer = (state: State, action: Action): State => {
switch (state.state) {
case "IDLE": {
switch (action.type) {
case "FETCH": {
return {
state: "FETCHING",
};
}
}
break;
}
case "FETCHING": {
switch (action.type) {
case "FETCH_SUCCESS": {
return {
state: "FETCHED",
todos: action.todos,
};
}
case "FETCH_FAILURE": {
return {
state: "ERROR",
error: action.error,
};
}
}
break;
}
case "ERROR": {
switch (action.type) {
case "FETCH": {
return {
state: "FETCHING",
};
}
}
break;
}
case "FETCHED": {
switch (action.type) {
case "FETCH": {
return {
state: "FETCHING",
};
}
}
break;
}
}
return state;
};
function Todo() {
const [state, setState] = React.useReducer(reducer, {
state: "IDLE",
});
React.useEffect(() => {
const fetchTodos = async () => {
const res = await fetch("some/api/todos");
return res.json();
};
switch (state.state) {
case "FETCHING":
fetchTodos()
.then((res) => dispatch({ type: "FETCH_SUCCESS", todos: res }))
.catch((e) => dispatch({ type: "FETCH_FAILURE", error: e.message }));
break;
}
}, [state]);
if (state.state === "ERROR") return <div>ERROR. {state.error}</div>;
return (
<div>
{state.state === "FETCHING" && <p>FETCHING...</p>}
{state.state === "FETCHED" && state.todos.length > 0 ? (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
) : (
<div>No todos</div>
)}
<button
onClick={() => dispatch({ type: "FETCH" })}
disabled={state.state === "FETCHING"}
>
Fetch todos
</button>
</div>
);
}
このようになりました。変わった点としては、
- ブール値のステートは無くなった
- この
Todo
コンポーネントは状態で管理されている('IDLE', 'FETCHING', 'FETCHED', 'ERROR' が遷移可能な状態) - useEffect はサイドエフェクト発火用としてしか使ってない
少しコード量的には多くなりましたが、遷移可能な状態やそのアクションは全て reducer
内で定義されているので、予測性が格段に増しました。ステートマシン以前のコードでは、<button />
のイベントハンドラにアプリロジックそのものがあったので、イベントハンドラが増えるごとにアプリフローを追うのが難しくなります。ボトムアップコードっていうやつですね。ですが ステートマシン導入後は全て一箇所にまとまっているので、保守性が高いコードとなってます。
それに加え、switch
で制御しているので、例えばコンポーネントの状態が FETCHING
の場合は何をどうしても FETCH
アクションを作動することはできません。なぜかというと action.type
内にそのアクションが定義されていないからです。なので、別の開発メンバーが間違って FETCH
アクションをどこかのイベントハンドラで実行可能にしてしまっても、しっかりガードされているので何も起こりません。
TypeScript の恩恵も大いに受けれます。例えば state
が FETCHED
の場合は、ちゃんと state
の他に todos
もあるのを認識してくれるので、簡単にどのようなペイロードがそのステートに付随するのかが分かります。
ざーーっと書いてしまいましたが、ステートマシンの魅力が少しでも伝われば嬉しいです。
上のようなやつにもっと機能を加えたライブラリが xstate や useStateMachine なので興味ある方は見てみてください!
Discussion