StateFlow drops what you needed and keeps what you didn't
Conflation turns two identical emissions into one, so a repeated event vanishes.
TL;DR
StateFlow is a value holder. It conflates — setting it to a value equal to the current one
emits nothing — and it replays its current value to every new subscriber.
Both behaviours are exactly right for state ("the screen is in this condition") and exactly wrong for events ("show a toast"). The second identical error never arrives; the last one arrives again after every rotation.
State in a StateFlow. Events in a Channel. The tell is whether a repeat of the same value
means something different the second time.
The case this post is about
A form screen with one state holder, which is the recommended shape and mostly correct:
// commonMain
data class FormState(
val email: String = "",
val submitting: Boolean = false,
val errorMessage: String? = null, // ← an event, hiding in state
val navigateToHome: Boolean = false, // ← also an event
)
class FormViewModel {
private val _state = MutableStateFlow(FormState())
val state: StateFlow<FormState> = _state.asStateFlow()
fun submit() = scope.launch {
_state.update { it.copy(submitting = true, errorMessage = null) }
when (val r = api.submit(_state.value.email)) {
is Err -> _state.update { it.copy(submitting = false, errorMessage = r.message) }
is Ok -> _state.update { it.copy(submitting = false, navigateToHome = true) }
}
}
}
The UI shows a snackbar when errorMessage != null and navigates when navigateToHome is true.
This works on the happy path, which is why it ships.
The symptom
Three bugs, all from the same two properties.
The second identical error showed no snackbar. Submit with a bad email, get "Invalid email address", dismiss the snackbar, submit again unchanged:
submit #1 errorMessage = "Invalid email address" → snackbar shown
submit #2 errorMessage = "Invalid email address" → nothing happens
Rotating the device re-navigated. Successful submit, navigate home, rotate, and the app navigates home again — from a screen the user had already left.
The snackbar reappeared after backgrounding. Same mechanism: a new subscriber receives the current value, and the current value still says there is an error.
The reproduction for the first one is three lines and makes the mechanism obvious:
_state.update { it.copy(errorMessage = "same") } // emits
_state.update { it.copy(errorMessage = "same") } // emits NOTHING
Why the obvious fix failed
"Clear the error after showing it." The standard patch, and it works:
LaunchedEffect(state.errorMessage) {
state.errorMessage?.let { snackbar.show(it); vm.errorShown() }
}
It is also a state machine with a reset step that every consumer must remember to run, and it is racy: two rapid errors can produce one shown message, because the clear and the next set interleave. I shipped this and the "second identical error" bug came back in a narrower form.
"Add a nonce to force a new value." Makes conflation stop:
data class FormState(val errorMessage: String? = null, val errorNonce: Int = 0)
It works, and it is an admission that the type is wrong — you are defeating conflation rather than using a type that does not conflate. It also still replays, so rotation re-shows.
"Use distinctUntilChanged(false)." Not available as an escape hatch on StateFlow;
conflation is part of the contract, not an operator applied on top.
"Keep events in state but compare by identity." data class equality is what drives
conflation, so this means abandoning data class, which breaks everything else about the state
holder.
The actual mechanism
StateFlow's contract has two clauses that matter here, and both are deliberate.
It is conflated, by equality. Assigning a value == to the current one is a no-op. This is
what makes StateFlow safe to update at high frequency — a recomposition-driven UI does not
re-render because a value was re-set to itself.
It always has a value, and replays it. Every new collector immediately receives the current value. This is what makes it correct for UI state: a composable subscribing at any moment gets the right picture without waiting.
Both clauses are correct for state and wrong for events, in opposite directions — which is why the two bugs look unrelated and have one cause.
Conflation loses repeats. Two identical errors in a row is one emission, so the second "Network unavailable" never reaches the UI. The user retries, sees nothing happen, and retries again.
Replay repeats what already happened. The toast that fired on failure fires again on rotation, because the new collector is handed the last value — which is a past event being delivered as a current one. This is the same mechanism that makes state correct: re-delivering the latest value is exactly right for "the screen is in an error state" and exactly wrong for "tell the user something just failed".
The distinction underneath is whether re-delivery is meaningful. State answers what is true now,
so sending it again is harmless and often necessary. An event asserts something happened, so
sending it again is a lie about the world — and no amount of care with distinctUntilChanged or
nullable sentinels changes which of those two a StateFlow is built to carry.
So StateFlow is a sampled signal, not a log. It answers "what is true now", and it
deliberately does not preserve how many times something became true.
An event has the opposite requirements:
Multiplicity is the meaning. Two identical errors are two notifications. Conflation destroys information that is the entire payload.
It must be consumed once. Replay means a new subscriber re-handles something already handled. Rotation is a new subscriber.

The test that decides which you need:
If the same value arriving twice means something different the second time, it is an event. If re-sending the same value should change nothing, it is state.
"Loading" is state — being told twice changes nothing. "Show error" is an event — being told twice means two errors.
The fix
Split the two, and let each use the type whose semantics match.
// commonMain
data class FormState(
val email: String = "",
val submitting: Boolean = false,
val emailValid: Boolean = true, // state: a condition of the form
)
sealed interface FormEvent {
data class ShowError(val message: String) : FormEvent
data object NavigateHome : FormEvent
}
class FormViewModel {
private val _state = MutableStateFlow(FormState())
val state: StateFlow<FormState> = _state.asStateFlow()
// BUFFERED, not conflated. Capacity so a fast producer does not suspend.
private val _events = Channel<FormEvent>(capacity = 16)
val events: Flow<FormEvent> = _events.receiveAsFlow()
fun submit() = scope.launch {
_state.update { it.copy(submitting = true) }
when (val r = api.submit(_state.value.email)) {
is Err -> {
_state.update { it.copy(submitting = false, emailValid = false) }
_events.send(FormEvent.ShowError(r.message)) // every time
}
is Ok -> {
_state.update { it.copy(submitting = false) }
_events.send(FormEvent.NavigateHome)
}
}
}
}
// the UI collects events once, and state continuously
LaunchedEffect(Unit) {
vm.events.collect { event ->
when (event) {
is FormEvent.ShowError -> snackbar.showSnackbar(event.message)
FormEvent.NavigateHome -> navController.navigate("home")
}
}
}
Four details worth naming:
receiveAsFlow(), not consumeAsFlow(). receiveAsFlow can be collected again after a
cancellation (rotation) without closing the channel. consumeAsFlow cancels the channel on
completion, so the next collector gets nothing — a rotation would silently stop all future events.
A Channel delivers to exactly one collector. That is correct for events: a navigation should
happen once, not once per observer. If you genuinely need multiple observers, that is a
SharedFlow with replay = 0 — and then you must accept that an event emitted while nobody is
subscribed is lost.
Capacity 16 rather than RENDEZVOUS. With the default, send suspends until a collector
receives, so an event emitted during a rotation gap would block the ViewModel coroutine. A small
buffer holds events across the gap.
emailValid stayed in state, and ShowError became an event. Same underlying failure,
two different surfaces: the field's red border is a condition (state), the snackbar is a
notification (event). Splitting them is the point, not moving everything to events.
How we proved it
Three assertions, one per symptom.
Two identical errors deliver twice:
// commonTest
@Test
fun repeatedIdenticalErrorsBothArrive() = runTest {
val received = mutableListOf<FormEvent>()
val job = launch { vm.events.collect { received += it } }
api.nextResult = Err("Invalid email address")
vm.submit(); advanceUntilIdle()
vm.submit(); advanceUntilIdle()
assertEquals(2, received.count { it is FormEvent.ShowError })
job.cancel()
}
before (StateFlow): expected 2, got 1
after (Channel): 2
Rotation does not replay a consumed event:
@Test
fun eventIsNotRedeliveredToANewCollector() = runTest {
val first = mutableListOf<FormEvent>()
val job1 = launch { vm.events.collect { first += it } }
api.nextResult = Ok
vm.submit(); advanceUntilIdle()
job1.cancel() // screen destroyed
val second = mutableListOf<FormEvent>()
val job2 = launch { vm.events.collect { second += it } } // recreated
advanceUntilIdle()
assertEquals(1, first.size)
assertEquals(0, second.size) // ← 1 with StateFlow
job2.cancel()
}
And an event emitted during the rotation gap is not lost — which is what the buffer is for, and the case I would have missed without writing it:
@Test
fun eventDuringRotationGapSurvives() = runTest {
val job1 = launch { vm.events.collect { } }
job1.cancel() // no collector now
api.nextResult = Err("boom")
vm.submit(); advanceUntilIdle() // send with nobody listening
val received = mutableListOf<FormEvent>()
val job2 = launch { vm.events.collect { received += it } }
advanceUntilIdle()
assertEquals(1, received.size) // buffered, then delivered
job2.cancel()
}
That third test is the one that justifies capacity = 16 over the default, and it fails with
RENDEZVOUS.
One honest limit: this is tested under virtual time with a cancel/recreate standing in for a real
rotation. It verifies the channel semantics, not the Android lifecycle integration — and
Channel's single-collector delivery means if you ever add a second observer of events, the two
will split the stream rather than both receive it. That is a real sharp edge of this fix and the
reason to prefer it only while events have exactly one consumer.
What this generalizes to
Match the data structure to whether multiplicity is meaningful. A set versus a list, a gauge
versus a counter, a cache versus a queue, StateFlow versus Channel. The question is always the
same: does the same value arriving twice mean something different the second time?
Replay is a feature with a cost. Anything that re-delivers on subscribe — a replaying subject, a persisted last-value, a retained fragment's state — will re-trigger whatever the consumer does with it. Ask what happens when a new subscriber appears, because on mobile one always will.
A workaround that defeats a type's behaviour is telling you the type is wrong. The nonce field worked. Needing it meant conflation was fighting the use case, and the cheaper answer was the type that does not conflate.
One failure can produce both a state change and an event. The error made the field invalid (state) and needed a snackbar (event). Reaching for "events or state" as an either/or is the mistake; most interactions produce some of each.
Related
- A Flow is cold, and that is why your ViewModel fires three network calls — the other half of choosing a stream type
- runTest skips your delays, which is why the flaky test passed — what these virtual-time tests do and do not prove
- A shared ViewModel has no lifecycle, so you have to give it one — where the rotation gap comes from
Keep reading
This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".