← 記事一覧に戻る
テクノロジー

「あり得ない状態」を型で防ぐUI設計

「あり得ない状態」を型で防ぐUI設計

フロントエンドの「あり得ない状態」バグとは何か

「ローディング中なのにボタンが押せてしまう」「エラーが出たあとなのに、直前の古いデータが表示され続ける」——こうした不具合は、実装のロジックミスというより、状態の設計不足が原因であることが多いです。

UIの多くは、次の4つの状態を行き来しています。

  • idle — 何もしていない初期状態
  • loading — データ取得・送信中
  • success — 取得・送信が完了した状態
  • error — 失敗した状態

この4状態をそれぞれ独立した真偽値(isLoading, isError, data)で管理すると、本来ありえない組み合わせ——たとえばisLoading: trueisError: 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を一気に置き換える必要はありません。次の条件に当てはまる箇所から優先的に着手すると効果的です。

  • isLoadingisErrordataなど、独立した状態変数が3つ以上ある
  • □ 過去に「ローディング表示とエラー表示が同時に出た」ような不具合報告がある
  • □ 非同期処理(API呼び出し)の結果を画面に出している
  • □ 状態のリセット処理(setError(null)など)を複数箇所に書いている

1画面ずつ移行し、switchで全パターンを列挙し直すだけでも、レビュー中に「このケース、考慮されていなかった」という発見があるはずです。

Bugoon での実践

状態バグは、実装だけでなく「発見された時点でどう記録するか」でも修正のしやすさが変わります。Bugoonのウィジェットを埋め込んでおけば、QA担当者や非エンジニアが気づいた瞬間にスクリーンショットへ直接注釈を付けて報告できます。「ローディングとエラーが同時に出ている」といった一目でわかる不具合ほど、テキストでの説明よりも画像のほうが早く正確に伝わります。

さらにBugoonは、クリックやスクロールといった操作ステップも記録するため、開発者は「どの操作の直後に、あり得ない状態に入ったか」を再現手順から追えます。報告はそのままGitHub Issueに連携され、カンバンボードで対応状況を追跡できるほか、MCPサーバー経由でClaude CodeやCursorに直接渡して修正を試みることもできます。

報告時にその画面がどの状態(loading・error・successのいずれか)だったかが自動で分かれば、状態バグの再現はさらに早くなるかもしれません。今回紹介した判別可能なユニオン型の設計と合わせて、記録の仕組み側からも「あり得ない状態」を減らしていく余地はまだありそうです。

チームのバグ報告を、もっとスムーズに。

Bugoon は無料で始められます。サイトにタグを 1 行追加するだけで、QA と開発の往復がなくなります。

無料で始める