第6回:イベントは State にするな

― Snackbar / Navigation / Toast が UI を壊す理由 ―

※ChatGPTを使用して記事を作成しています。

はじめに

Jetpack Compose を使っていると、ある時ふとこんな設計になります。

data class UiState(
    val showSnackbar: Boolean = false,
    val navigateToDetail: Boolean = false
)

そして Composable 側でこう書きます。

if (state.showSnackbar) {
    Snackbar(...)
}

一見、状態駆動UIとして正しそうに見えます。
しかしこれは、Composeにおける代表的な地雷設計です。

この記事では、

  • なぜイベントを State にしてはいけないのか
  • 何が起きるのか
  • どう設計すべきか

失敗例 → 修正版 → 設計原則 の順で整理します。

1. State と Event は「似ているが別物」

まず最初に、定義をはっきりさせます。

✅ State とは

  • 今この瞬間のUIの姿
  • 再コンポーズされても保持されてよい
  • 同じ値なら同じUIになる

例:

  • ローディング中か
  • エラーか
  • 表示するリスト

❌ Event とは

  • 一度だけ起きてほしい出来事
  • 再コンポーズで再実行されてはいけない
  • 消費されるもの

例:

  • Snackbar を出す
  • Toast を出す
  • 画面遷移する

👉 State は「存在」
Event は「出来事」

ここを混同した瞬間、UI は壊れ始めます。

2. 失敗例①:Snackbar を State で制御する

❌ よくある実装

data class UiState(
    val showSnackbar: Boolean = false
)
if (state.showSnackbar) {
    Snackbar { Text("保存しました") }
}

ViewModel 側:

fun onSave() {
    _state.update { it.copy(showSnackbar = true) }
}

❌ 何が問題か?

  • 再コンポーズで 何度も Snackbar が出る
  • 画面回転で 再表示
  • Navigation 戻りで 再表示
  • 「一度きり」が保証されない

つまり、

Snackbar が State に縛られてしまう

3. 失敗例②:Navigation を State にする

data class UiState(
    val navigateDetail: Boolean = false
)
if (state.navigateDetail) {
    navController.navigate("detail")
}

起きること

  • 戻ると再遷移
  • 二重遷移
  • BackStack 崩壊
  • 再現しない不具合

👉 「NavigationでBackStackが消えた日」案件

4. なぜ State にすると壊れるのか?

Compose の本質はこれです。

State は「再評価される前提」で作られている

  • 再コンポーズはいつ起きてもおかしくない
  • 同じ State なら同じ処理が再実行される

しかし Event は、

「一度きり」であることが前提

この前提が根本的に噛み合いません。

5. 正解①:LaunchedEffect + Event

Snackbar の正解例

ViewModel:

private val _event = MutableSharedFlow<UiEvent>()
val event = _event.asSharedFlow()

sealed class UiEvent {
    data class ShowSnackbar(val message: String) : UiEvent()
}
fun onSave() {
    viewModelScope.launch {
        _event.emit(UiEvent.ShowSnackbar("保存しました"))
    }
}

Composable:

LaunchedEffect(Unit) {
    viewModel.event.collect { event ->
        when (event) {
            is UiEvent.ShowSnackbar ->
                snackbarHostState.showSnackbar(event.message)
        }
    }
}

✔ 一度きり
✔ 再コンポーズ安全
✔ 状態と分離

6. 正解②:Navigation も Event にする

sealed class UiEvent {
    object NavigateDetail : UiEvent()
}
LaunchedEffect(Unit) {
    viewModel.event.collect { event ->
        when (event) {
            UiEvent.NavigateDetail ->
                navController.navigate("detail")
        }
    }
}

Navigation は絶対に State にしない。

7. よくある誤解:「SingleLiveEvent は?」

結論から言います。

Compose では SingleLiveEvent を使う理由はありません

理由:

  • Flow / SharedFlow が標準
  • ライフサイクル安全
  • 再購読問題がない

SingleLiveEvent は View時代の苦肉の策です。

8. State と Event の正しい責務分離

ViewModel が持つもの

  • UI State(画面の状態)
  • UI Event(一度きりの通知)

Composable がやること

  • State を描画する
  • Event を「消費」する

9. 設計チェックリスト(実務用)

次の質問に YES なら Event にすべきです。

  • 一度だけ起きてほしい?
  • 再表示されたら困る?
  • 履歴として残らなくていい?
  • UIの見た目そのものではない?

まとめ

Compose で最も多い設計ミスは、

イベントを State にしてしまうこと

です。

  • State は「存在」
  • Event は「出来事」

この線を越えない限り、

  • 無限再実行
  • 再現しないバグ
  • BackStack 崩壊

は ほぼ防げます。

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