React NativeやFlutterでモバイルアプリを開発していて、isLoading・hasError・isEmptyといったbooleanフラグが増え続けて収拾がつかなくなった経験はないでしょうか。この記事では、UI状態管理の設計判断について、どのタイミングでどの手法を選ぶべきかを整理します。
画面が増えるほど、ローディング中なのにエラーも表示されている、データがあるのに空表示が出る、といった「本来ありえない組み合わせ」がバグとして表面化します。これはbooleanの数だけ状態の組み合わせが指数的に増えることが原因です。isLoading・hasError・isEmpty・hasDataの4つのbooleanがあれば、理論上16通りの組み合わせが発生し得ますが、実際に意味を持つのは4〜5通り程度です。残りは「あってはならない状態」であり、これがUI崩れやクラッシュの温床になります。
この問題への対処法としてよく挙がるのが、状態機械(ステートマシン、取り得る状態を有限個に限定しモデル化する考え方)としてUI状態を表現する手法です。KotlinのsealedクラスやTypeScriptのunion型を使い、Loading・Content・Empty・Errorのように「同時に存在しえない状態」を型レベルで排他的に定義します。ただしこれは書き方の変更だけでなく、設計思想そのものを変えることになるため、導入判断には軸が必要です。
判断軸1: 状態の組み合わせ爆発が起きているか
まず確認すべきは、現在のコードでbooleanフラグが何個あるかです。3個以上のbooleanが同じコンポーネント内で共存している場合、組み合わせ爆発のリスクが高い状態です。
目安として、isLoading, isRefreshing, hasError, errorMessage, isEmptyのように5個前後のフラグが並んでいるコンポーネントがあれば、それは状態機械への移行を検討する具体的なシグナルです。逆に単一のトグル(開閉状態など)だけならbooleanのままで十分です。
判断軸2: チームのTypeScript/Kotlin習熟度
sealed classやdiscriminated union(判別可能なunion型、共通のタグプロパティで型を判別する仕組み)を使った状態表現は、型システムの理解が前提になります。
TypeScriptであれば、以下のようなunion型でOrdersUiStateを表現できます。
type OrdersUiState =
| { type: "loading" }
| { type: "content"; orders: Order[]; isRefreshing: boolean }
| { type: "empty"; canRetry: boolean }
| { type: "error"; message: string; canRetry: boolean };switch文でtypeを分岐すれば、TypeScriptのnarrowing(型の絞り込み)により各ケースで対応するプロパティのみアクセスできます。チームがこのパターンに慣れていない場合、学習コストとレビュー負荷が一時的に増える点は見込んでおく必要があります。
判断軸3: 状態遷移のテスト容易性
booleanの組み合わせで状態を管理していると、テストケースも組み合わせの数だけ書く必要が出てきます。状態機械化すると、取りうる状態が有限個に限定されるため、テストすべきケースが明確になります。
例えばContent状態からError状態への遷移、Error状態からRetryを経てLoading状態に戻る遷移など、遷移パターンを列挙してユニットテストを書きやすくなります。QAやテスト自動化を重視するプロジェクトほど、この恩恵は大きくなります。
判断軸4: オフライン対応やリトライ処理の複雑さ
ネットワークが不安定な環境での挙動、トークンの期限切れ、二重タップによる重複送信といった失敗系のハンドリングが多いアプリほど、状態の種類そのものが増えます。
オフラインファースト(ネットワーク接続を前提とせず、ローカルデータを主として動作させる設計)を志向するアプリでは、Content状態にisRefreshingやisStaleといったサブ状態を持たせる必要が出てきます。この複雑さをbooleanの並列管理で吸収しようとすると、早晩破綻します。
選択肢の比較
| 手法 | 向いている規模 | 学習コスト | テスト容易性 |
|---|---|---|---|
| boolean併用 | 画面数が少ない・状態が2〜3種 | 低い | 低い |
| sealed class / discriminated union | 画面が増え続けるプロダクト | 中〜高 | 高い |
| 状態管理ライブラリ導入(Redux/Zustand等) | 複数画面で状態共有が必須 | 高い | 高い |
ケース別の推奨
単一画面のプロトタイプや、社内向けの小規模ツールであれば、boolean併用のままで問題ありません。無理にsealed classを導入すると、コード量が増えるだけで恩恵が実感しにくいです。
一方、注文一覧・チェックアウト・プロフィールなど複数画面を持つプロダクションアプリで、かつローディング・エラー・空状態・再試行の4パターン以上が各画面に存在するなら、状態機械化を選ぶ判断が合理的です。特にKotlinのAndroidアプリであればsealed interfaceがそのまま言語機能として使えるため、追加ライブラリなしで導入できます。
React Native環境では、TypeScriptのunion型に加えてZustandやJotaiのような軽量な状態管理ライブラリを組み合わせるケースも一般的です。Reduxほど大掛かりな仕組みが不要な中規模アプリであれば、union型ベースの状態表現とローカルなuseReducerの組み合わせで十分に対応できます。
あえて見送るべき条件
チームメンバーの大半がTypeScriptの型システムやKotlinのsealed classに不慣れで、かつリリースまでの期限が近い場合は、無理に移行しないほうが安全です。設計変更は動作確認とレビューの手間を伴うため、リリース直前のタイミングでは避けるべきです。
また、画面数が少なく将来的な拡張予定もない管理画面やPoC(概念実証)レベルのアプリであれば、状態機械化のコストに対してリターンが見合いません。booleanのままシンプルに保つ判断も十分に合理的です。
確認すべきこと
実際に自分のプロジェクトで判断するなら、まず該当コンポーネントのstate定義を開き、booleanの数を数えてみてください。3個以上あり、かつ組み合わせとして意味をなさない状態が発生しうるなら、移行を検討する具体的なサインです。
合わせて、直近3ヶ月のバグ報告やクラッシュログを振り返り、「ローディング中にエラー表示が出る」といったUI状態の不整合が実際に発生していたかを確認すると、判断の裏付けになります。
まとめ
UI状態管理の選択は、画面の複雑さと失敗系ハンドリングの量で決まります。boolean併用は小規模なら十分ですが、状態の組み合わせが増えるほど破綻しやすくなります。
判断の第一歩として、対象コンポーネントのboolean数を数え、過去のバグログで状態不整合が起きていないか確認してみてください。それだけで、移行すべきかどうかの具体的な材料が手に入ります。