kotlin-flow-state-event-modeling について
状態とイベントのモデル化に使う Flow プリミティブの選択と利用を支援する Kotlin スキルです。再生、配信、同期読み取りの要件に合わせ、共有状態の問題を避けます。
主な機能
- 01StateFlow、SharedFlow、Channel、Flow の特性を解説
- 02不在や読み込み状態のモデル化を案内
- 03StateFlow のアトミックな更新を提示
- 04stateIn の共有と起動方式を解説
- 05同期的な value アクセスの維持方法を説明
活用シーン
- 01一度だけのナビゲーションや Snackbar イベントのモデル化
- 02同期読み取りに使う状態ストリームの選択
- 03並行コルーチンによる更新消失の防止
- 04Flow の共有方法やプレースホルダー状態のレビュー
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ chrisbanes/ skills --skill kotlin-flow-state-event-modelingインストール後、Claude Code で/kotlin-flow-state-event-modelingを入力して読み込まれたか確認します。
詳細
Kotlin Flow:状態とイベントのモデリング
基本原則
リプレイ、ファンアウト、同期読み取りの要件に合ったプリミティブを選んでください。 StateFlow、SharedFlow、Channel を基盤とするフロー、コールド Flow は、バッファリング、各エミッションを受け取る対象、.value の有無がそれぞれ異なります。誤った選択をすると、イベントが失われたり、共有コルーチンがリークしたり、状態に偽のドメイン用センチネル値を持ち込むことになります。
このスキルを使う場面
次のような Kotlin コードを記述またはレビューしている場合です。
- 実際の値が非同期で取得されるため、
MutableStateFlow<T>(SomeSentinel)—NoUser、Empty、Loadingなど — を使っている .stateIn(...)をプロパティに代入せず、関数内で呼び出している.valueを同期的に読み取り、常に最新の値である必要があるフローにSharingStarted.WhileSubscribed(...)を使っている- ナビゲーションイベント、スナックバーなど、失われると問題になる一度限りのエミッションに
MutableSharedFlowを使っている - 利用側で引き続き同期的な
.valueが必要なStateFlowに.map { }を使っている MutableStateFlow.value = _state.value.copy(...)を使っている、またはupdate { ... }内でコストの高いオブジェクトを構築する更新コードを書いている
一度だけ処理する単一コンシューマーのイベントには SharedFlow
SharedFlow のデフォルトでは、リプレイバッファーがありません。エミッションの瞬間に収集しているものがなければ、イベントは失われます。ナビゲーションやスナックバーなどのイベントを一度だけ処理する単一の UI コンシューマーには、Flow として公開するバッファー付き Channel のほうが、セマンティクスに合う場合がよくあります。
// ❌ BAD
private val _navEvents = MutableSharedFlow<NavigationEvent>()
val navEvents: SharedFlow<NavigationEvent> = _navEvents.asSharedFlow()
// ✅ GOOD
private val _navEvents = Channel<NavigationEvent>(Channel.BUFFERED)
val navEvents: Flow<NavigationEvent> = _navEvents.receiveAsFlow()
Channel.receiveAsFlow() はブロードキャストではなくファンアウトです。複数のコレクターがいる場合、各イベントはいずれか1つのコレクターに配信されます。Channel.BUFFERED の容量には上限があるため、送信が一時停止したり、trySend が失敗したりすることがあります。複数のオブザーバーが同じイベントをすべて受け取る必要がある場合は、明示的な状態、永続ストレージ、または意図的に設定した SharedFlow を使ってください。
無効なセンチネル値で汚染された StateFlow
StateFlow には初期値が必要です。実際の値が非同期で取得される場合、開発者は NoUser、EmptyUser、プレースホルダー ID などの偽のドメイン値を用意することがあります。その結果、すべての利用側でそのセンチネル値を実データとして扱わなければならなくなります。
// ❌ BAD — sentinel leaks into the type
class UserSession(private val db: Db) {
private val _user = MutableStateFlow<User>(NoUser)
val user: StateFlow<User> = _user.asStateFlow()
init { scope.launch { _user.value = db.load() } }
}
解決策の1つは段階化です。実際の値が存在するまでは StateFlow を公開しないようにします。
// ✅ GOOD — bootstrap suspends; observers only see real users
class UserSession(private val db: Db) {
private var _user: MutableStateFlow<User>? = null
val user: StateFlow<User>
get() = checkNotNull(_user) { "Call login() first" }
suspend fun login() {
_user = MutableStateFlow(db.load())
}
}
値がない状態、読み込み中、エラーが実際の状態である場合は、それを明示的にモデル化してください(User?、sealed interface UserUiState、Result など)。問題なのは、偽のドメイン値を実データのように見せかけることであり、初期値があること自体ではありません。
update { ... } で MutableStateFlow を変更する
.value を読み取って書き戻すよりも、MutableStateFlow.update { current -> ... } を使うことをおすすめします。update は最新の状態に対して変換をアトミックに適用するため、複数のコルーチンが同じ状態を変更するときの更新の取りこぼしを防げます。
// BAD — read/modify/write can lose concurrent updates.
_state.value = _state.value.copy(
selectedId = id,
details = details,
)
// GOOD — transform starts from the latest state.
_state.update { current ->
current.copy(
selectedId = id,
details = details,
)
}
現在の状態が必要な場合を除き、オブジェクトの生成は update ブロックの外で行ってください。更新ラムダは再試行されることがあるため、コストの高い処理や副作用が含まれていると、複数回実行される可能性があります。
// GOOD — details does not depend on current state, so build it once.
val details = Details.from(response)
_state.update { current ->
current.copy(details = details)
}
// GOOD — derived value depends on current state, so compute it inside.
_state.update { current ->
val nextItems = current.items.replaceById(updatedItem)
current.copy(items = nextItems)
}
ブロック内では、状態を純粋かつ高速に変換してください。値をブロックの前に取得しておく場合を除き、ネットワーク呼び出し、データベースへの書き込み、ログ出力などの副作用、ランダム ID の生成、時刻の読み取りは行わないでください。
関数内での stateIn()
// ❌ BAD — new sharing coroutine every call
fun getPreferences(): StateFlow<Prefs> =
repo.prefsFlow.stateIn(scope, SharingStarted.Eagerly, Prefs.Default)
getPreferences() を呼び出すたびに、新しいコルーチンが scope 上で起動し、完了しないまま残ります。繰り返し読み取ると、すぐにパフォーマンスが低下します。
// ✅ GOOD — one shared instance, computed once
val preferences: StateFlow<Prefs> =
repo.prefsFlow.stateIn(viewModelScope, SharingStarted.Eagerly, Prefs.Default)
同期的な .value と WhileSubscribed
SharingStarted.WhileSubscribed(timeout) は、アクティブなコレクターがいなくなると上流との接続を切断します。切断中に .value を読み取ると、最後にキャッシュされた値が返ります。この値は古いか、初期値のままの可能性があります。
ルール: .value が常に最新である必要がある場合や、アクティブなコレクターがいなくても初期化済みである必要がある場合は、SharingStarted.Eagerly または明示的な初期化を使ってください。古い値やキャッシュされた値を許容でき、利用側が主に非同期で収集する場合は、WhileSubscribed で問題ありません。
StateFlow の .map では .value が失われます
// ❌ BAD — `name.value` won't compile; it's now a plain Flow
val name: Flow<String> = userState.map { it.name }
同期的な .value が必要な場合は、チェーンを .stateIn(...) で終端させます。
// ✅ GOOD
val name: StateFlow<String> = userState
.map { it.name }
.stateIn(viewModelScope, SharingStarted.Eagerly, userState.value.name)
コミュニティ製の「派生 StateFlow」ユーティリティは、.value を読み取るたびに変換処理を実行します。高速で冪等な変換に限って使用してください。基本的には .stateIn(...) を使います。
選択の指針:どの Flow 型を使うべきか?
| 必要なもの | プリミティブ |
|---|---|
| 常に値を持ち、非同期コレクターと同期コードの両方から読み取られる状態 | StateFlow。.value が重要な場合は、SharingStarted.Eagerly を使うことがよくあります |
ホットストリーム、複数のサブスクライバー、同期的な .value は不要 | SharedFlow |
| 1 つのコンシューマーに対する個別のイベント、厳密に 1 回の受け渡し | Channel(BUFFERED).receiveAsFlow() を検討します |
| コレクションごとに 1 つのコンシューマーを持つコールドストリーム | 通常の Flow |
SharedFlow を使おうとしている場合は、次の点を確認してください。値の欠落が不具合になるか、また何個のコンシューマーが値を受け取る必要があるでしょうか?1 つのコンシューマーがイベントを厳密に 1 回処理する必要がある場合は、Channel が適している可能性があります。すべてのオブザーバーがイベントを受け取る必要がある場合は、永続的な状態としてモデル化するか、ブロードキャストストリームを意図的に設定してください。
クイックリファレンス
| 症状 | 問題 | 修正 |
|---|---|---|
MutableStateFlow<X>(FakeDomainValue) | 無効なプレースホルダーのデフォルト値 | 値がない状態を明示的にモデル化するか、段階的な初期化を使います |
1 つのコンシューマー向けナビゲーション/スナックバーに MutableSharedFlow<Event> を使う | デフォルトのイベントストリームでは値が失われます | Channel(BUFFERED).receiveAsFlow() を検討します |
fun foo() = flow.stateIn(...) | 呼び出しごとに共有コルーチンが作成されます | val または共有インスタンスにします |
WhileSubscribed を使い、.value を最新の状態または初期化済みの状態にする必要がある | 古いデータまたは初期データ | SharingStarted.Eagerly または明示的な初期化を使います |
stateFlow.map { ... } を状態として使用する | .value が失われます | .stateIn(...) で終端させます |
_state.value = _state.value.copy(...) | 非アトミックな読み取り/変更/書き込み | _state.update { it.copy(...) } |
現在の状態を使わない高コストなオブジェクト生成を update { ... } 内で行う | update が再試行されると処理が繰り返される可能性があります | update の前に生成し、現在の状態を使う変換だけを内部に置きます |
レビュー時の危険信号
| 考え | 実際のところ |
|---|---|
「サブスクライバーが複数いるので SharedFlow が必要です」 | サブスクライバーが複数いると、セマンティクスが変わります。Channel.receiveAsFlow() はブロードキャストではありません。イベントモデルを意図的に選択してください。 |
「リソースを節約するために WhileSubscribed を使います」 | .value の古い値や初期値を許容できる場合に限ります。適用する前に確認してください。 |
| 「実際のデータが読み込まれるまでセンチネル値を使います」 | コンシューマーはそれを実際のドメイン値として扱います。明示的な UI/状態のモデリング、または段階的な初期化を優先してください。 |
「便利なので、新しいオブジェクトを update 内で構築します」 | ラムダは再試行される可能性があります。現在の状態に依存しない限り、外部で構築してください。 |
関連項目
kotlin-control-flow— 状態やイベントをモデル化する際のwhen、ガード条件、網羅性、スマートキャスト、早期リターンの選択。kotlin-coroutines-structured-concurrency— スコープの所有、初期化時の起動、ファイア・アンド・フォーゲットの境界、キャンセル、runBlockingcompose-side-effects— イベントフローの収集と Compose での副作用の接続compose-state-hoisting— 状態ホルダーからプレーンな状態駆動型 UI にフローを公開する場所