第12回:UI State はどこまで分解すべきか問題

― 巨大 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段階あります。

  1. 正規化(矛盾をなくす)
  2. 粒度設計(責務単位に分ける)

今回扱ったのは後者です。

巨大 UiState は一見整理されているようで、実は「責務の塊」になりやすい。

逆に分解しすぎれば、整合性が壊れます。

最終的に目指すのは、

「この画面は今どんな状態か?」を
構造で説明できる設計

です。

タイトルとURLをコピーしました