「あり得ない状態」を型で防ぐUI設計
フロントエンドの「あり得ない状態」バグとは何か
「ローディング中なのにボタンが押せてしまう」「エラーが出たあとなのに、直前の古いデータが表示され続ける」——こうした不具合は、実装のロジックミスというより、状態の設計不足が原因であることが多いです。
UIの多くは、次の4つの状態を行き来しています。
- idle — 何もしていない初期状態
- loading — データ取得・送信中
- success — 取得・送信が完了した状態
- error — 失敗した状態
この4状態をそれぞれ独立した真偽値(isLoading, isError, data)で管理すると、本来ありえない組み合わせ——たとえばisLoading: trueとisError: trueが同時に立つ状態——を型システムが防いでくれません。実際に動かしてみて初めて「ローディングスピナーとエラーメッセージが両方出ている」ことに気づく、というケースが起きます。
よくある「あり得ない状態」の実例
設計レビューやコードレビューで見つかった、実際によくあるパターンを3つ挙げます。
1. 二重送信を防げないローディング状態
フォーム送信ボタンをdisabled={isLoading}だけで制御していると、isLoadingの更新が非同期処理のあと(たとえばawaitの直後)にずれて、その一瞬にもう一度クリックできてしまうケースがあります。
2. エラー後に古いデータが残る
一覧画面で再取得に失敗したとき、errorだけをセットしてdataを消し忘れると、画面には「古い一覧」と「エラーメッセージ」が同時に表示されます。ユーザーから見ると、成功しているのか失敗しているのか判断できません。
3. 成功後にエラーメッセージが消えない
リトライに成功したのに、直前のエラーメッセージのstateをリセットし忘れて表示され続けるパターンです。
これら3つはすべて、「状態を独立した変数の集合」として扱っていることが根本原因です。
型で「あり得ない組み合わせ」を作れなくする
解決策は、状態を判別可能なユニオン型(discriminated union)として1つの値にまとめることです。
type FetchState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; message: string };
function Report({ state }: { state: FetchState<BugReport[]> }) {
switch (state.status) {
case "idle":
return null;
case "loading":
return <Spinner />;
case "success":
return <List items={state.data} />;
case "error":
return <ErrorMessage text={state.message} />;
}
}このように書くと、state.status === "error"の分岐ではstate.dataに絶対にアクセスできません(TypeScriptの型上、存在しないため)。「エラーなのに古いデータが表示される」というバグは、実行時に気づく前にコンパイルエラーとして弾かれます。
switch文にdefaultを書かず、代わりにnever型を使った網羅性チェックを入れておくと、状態を1つ増やしたときの実装漏れも防げます。
function assertNever(x: never): never {
throw new Error(`Unhandled state: ${JSON.stringify(x)}`);
}既存コードへの適用チェックリスト
すべてのuseStateを一気に置き換える必要はありません。次の条件に当てはまる箇所から優先的に着手すると効果的です。
- □
isLoading・isError・dataなど、独立した状態変数が3つ以上ある - □ 過去に「ローディング表示とエラー表示が同時に出た」ような不具合報告がある
- □ 非同期処理(API呼び出し)の結果を画面に出している
- □ 状態のリセット処理(
setError(null)など)を複数箇所に書いている
1画面ずつ移行し、switchで全パターンを列挙し直すだけでも、レビュー中に「このケース、考慮されていなかった」という発見があるはずです。
Bugoon での実践
状態バグは、実装だけでなく「発見された時点でどう記録するか」でも修正のしやすさが変わります。Bugoonのウィジェットを埋め込んでおけば、QA担当者や非エンジニアが気づいた瞬間にスクリーンショットへ直接注釈を付けて報告できます。「ローディングとエラーが同時に出ている」といった一目でわかる不具合ほど、テキストでの説明よりも画像のほうが早く正確に伝わります。
さらにBugoonは、クリックやスクロールといった操作ステップも記録するため、開発者は「どの操作の直後に、あり得ない状態に入ったか」を再現手順から追えます。報告はそのままGitHub Issueに連携され、カンバンボードで対応状況を追跡できるほか、MCPサーバー経由でClaude CodeやCursorに直接渡して修正を試みることもできます。
報告時にその画面がどの状態(loading・error・successのいずれか)だったかが自動で分かれば、状態バグの再現はさらに早くなるかもしれません。今回紹介した判別可能なユニオン型の設計と合わせて、記録の仕組み側からも「あり得ない状態」を減らしていく余地はまだありそうです。