― 巨大 UiState が静かに設計を壊す ―
※ChatGPTを使用して記事を作成しています。
前回は「UI State を正規化しないと破綻する理由」を扱いました。
今回はその次の段階です。
UI State は、どこまで分解するのが正解なのか?
Compose を書いていると、必ずこの壁に当たります。
- 画面ごとに1つの巨大 UiState を持つべき?
- フォームはフィールドごとに State を分ける?
- List のアイテム単位に状態を持つ?
- 子Composableに State を渡す?分解する?
これは正規化とは別の問題です。
正規化が「矛盾をなくす話」だとすれば、
今回のテーマは 「粒度をどう決めるか」 の話です。
■ まずは失敗例から
よくある巨大 UiState です。
data class UiState(
val screenTitle: String = "",
val isLoading: Boolean = false,
val errorMessage: String? = null,
val formInput: String = "",
val isButtonEnabled: Boolean = false,
val items: List<Item> = emptyList(),
val selectedItemId: String? = null,
val showDialog: Boolean = false,
val isRefreshing: Boolean = false
)
一見すると「全部まとまっていて分かりやすい」ように見えます。
しかし、実務では次の問題が発生します。
問題①:変更の影響範囲が広すぎる
_state.update { it.copy(formInput = newValue) }
この1行で、
- 画面全体が再評価される
- items 表示部分も再計算対象になる
- ダイアログ部分も影響を受ける
Compose は差分最適化しますが、
State が1つに集約されていると、再評価の起点が広すぎるのです。
問題②:責務の境界が曖昧になる
この UiState には、
- 画面全体の状態
- フォームの入力状態
- ダイアログの状態
- リストの選択状態
が混在しています。
つまり、
「誰の状態なのか」が曖昧
になっています。
これは正規化とは別の破綻です。
■ 分解しすぎても問題は起きる
逆に、極端な例もあります。
val title = MutableStateFlow("")
val isLoading = MutableStateFlow(false)
val formInput = MutableStateFlow("")
val isButtonEnabled = MutableStateFlow(false)
val items = MutableStateFlow<List<Item>>(emptyList())
今度はこうなります。
- 状態の整合性が崩れる
- どれが画面の“現在地”か分からない
- テストが書きづらい
- UI 側で組み合わせ解釈が始まる
これは第8回で扱った「正規化問題」に逆戻りです。
粒度設計の基本原則
では、どう考えるべきか。
原則①:責務単位で分ける
画面には通常、いくつかの「関心事」があります。
例:
- 画面全体のロード状態
- フォーム入力状態
- ダイアログ表示状態
- リスト表示状態
これらを 意味単位で分解します。
改善例:構造で分解する
data class UiState(
val contentState: ContentState = ContentState.Loading,
val formState: FormState = FormState(),
val dialogState: DialogState = DialogState.Hidden
)
sealed class ContentState {
object Loading : ContentState()
data class Data(val items: List<Item>) : ContentState()
data class Error(val message: String) : ContentState()
}
data class FormState(
val input: String = "",
val isButtonEnabled: Boolean = false
)
sealed class DialogState {
object Hidden : DialogState()
object ConfirmDelete : DialogState()
}
ここで起きていることは、
- 横方向(矛盾排除) → sealed class
- 縦方向(粒度整理) → 構造分割
です。
再コンポーズとの関係
Compose において粒度は、
- 再評価範囲
- 可読性
- 再利用性
に直結します。
例えば、
@Composable
fun FormSection(formState: FormState)
のように分離していれば、
- formState が変わったときだけ再評価
- 他のUIと独立
になります。
巨大な UiState を丸ごと渡すと、
@Composable
fun FormSection(state: UiState)
となり、不要な再評価の起点になります。
分解の判断基準(実務向け)
次の質問で判断できます。
① その状態は別Composableで独立して描けるか?
→ YESなら分ける価値がある
② 変更頻度が極端に違うか?
→ 頻繁に変わるものは分離を検討
③ 別の画面でも再利用されるか?
→ モジュール化する
分解のしすぎに注意
ただし重要なのは、
分解は「意味」で行うこと
パフォーマンス目的だけで細分化すると、
- 可読性低下
- 状態の追跡困難
- 結合度上昇
につながります。
Compose は差分最適化を持っています。
設計を優先し、微細最適化は後回しが基本です。
■ まとめ
UI State の設計には2段階あります。
- 正規化(矛盾をなくす)
- 粒度設計(責務単位に分ける)
今回扱ったのは後者です。
巨大 UiState は一見整理されているようで、実は「責務の塊」になりやすい。
逆に分解しすぎれば、整合性が壊れます。
最終的に目指すのは、
「この画面は今どんな状態か?」を
構造で説明できる設計
です。

