Engineering

StateFlow drops what you needed and keeps what you didn't

Conflation turns two identical emissions into one, so a repeated event vanishes.

MobileByteSensei 9 min read
kmpcoroutinesarchitecture

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.

A conflated value holder beside a buffered channel: setting the same error message twice emits once, and a new subscriber after rotation receives the current value again and re-navigates — while the channel delivers both occurrences in order and delivers each exactly once, with nothing left to replay

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.

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".

Follow by RSS