StateFlow vs SharedFlow in Jetpack Compose: modeling UI state without the replay bug
When to use StateFlow and when to use SharedFlow in a Compose ViewModel — and the one-time-event bug that shows up when you mix them up.
Every Compose screen I’ve shipped eventually needs to answer the same question: does this piece of information describe what the screen looks like right now, or does it describe something that happened once? Get the answer wrong and you get a specific, annoying bug — a toast that reappears after rotation, a navigation event that fires twice, a “session complete” celebration that replays every time the user backgrounds and reopens the app. The fix isn’t a workaround. It’s picking the right Flow type for the job in the first place.
Kotlin gives you two hot flows that look interchangeable from the call site — both are collected with collectAsStateWithLifecycle() or a LaunchedEffect, both live in a ViewModel, both survive configuration changes. They are not interchangeable, and the difference is exactly the distinction above.
StateFlow: always has a value, always replays it
StateFlow is built for state — data that has a current value at every point in time, whether or not anyone is watching. A timer’s remaining seconds, a form’s validation errors, a list’s loading/error/success status. New collectors immediately get the latest value, and every collector always sees the same thing.
private val _uiState = MutableStateFlow(TimerUiState.Idle)
val uiState: StateFlow<TimerUiState> = _uiState.asStateFlow()
fun start(durationMinutes: Int) {
_uiState.value = TimerUiState.Running(
remainingSeconds = durationMinutes * 60,
)
}
That “always replays the latest value to new collectors” behavior is the entire point of StateFlow — it’s what makes a Compose screen correct after a configuration change. Rotate the device, the Composable recomposes, a fresh collector attaches, and it immediately renders the current state instead of a blank screen waiting for the next emission.
SharedFlow: describes something that happened
That same replay behavior is exactly wrong for a one-time event. Say a Pomodoro session finishes and you want to trigger a “well done” sound and a subtle celebration animation — something that should happen once, at the moment it happens, not every time the screen recomposes.
If that event is modeled as a StateFlow<Boolean> set to true on completion, every new collector — including the one created after a rotation, or after the user leaves the app and comes back — immediately sees true and replays the celebration. The fix people reach for first is usually a manual reset (_sessionComplete.value = false right after consuming it), which works until two collectors race to read it, or the reset runs on the wrong dispatcher and the flag never flips back in time.
SharedFlow with no replay is the correct primitive here, because it doesn’t hold a “current value” at all — it only emits to collectors that are actively subscribed at the moment of emission:
private val _events = MutableSharedFlow<TimerEvent>()
val events: SharedFlow<TimerEvent> = _events.asSharedFlow()
private fun onSessionComplete() {
viewModelScope.launch {
_events.emit(TimerEvent.SessionComplete)
}
}
A collector that wasn’t listening when the event fired simply never sees it — which is correct for a celebration animation, and would be wrong for the timer’s state. That asymmetry is the whole decision: if a late subscriber should see the current value, it’s state; if a late subscriber should have missed it, it’s an event.
The rule of thumb that actually holds up
I use one question to decide, and it’s held up across every screen I’ve built this way: would it be a bug for a brand-new collector to immediately receive this value?
- Timer state, form contents, loading flags, list contents → no, a new collector should see the current value →
StateFlow. - “Show this snackbar,” “navigate to this screen,” “play this sound” → yes, replaying it to a late subscriber is the bug →
SharedFlow(replay = 0).
The one exception worth knowing: SharedFlow(replay = 1) exists and looks like a middle ground, but in practice it’s just StateFlow without the “always has a value” guarantee and without .value for synchronous reads — there’s rarely a reason to reach for it over a real StateFlow when you’re modeling state, or a SharedFlow(replay = 0) when you’re modeling an event.
Wiring it into a Compose screen
The two get consumed differently, and mixing that up is the second-most-common version of this bug:
@Composable
fun TimerScreen(viewModel: TimerViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
TimerEvent.SessionComplete -> playCompletionSound()
}
}
}
TimerContent(uiState)
}
collectAsStateWithLifecycle() is for StateFlow — it needs a current value to expose as Compose state. events gets collected inside a LaunchedEffect instead, precisely because it has no current value to hold; it’s a stream to react to, not a value to read.
The takeaway
The bug this prevents is subtle enough that it usually ships before anyone notices — a duplicate toast on rotation, a re-triggered animation after backgrounding the app — because everything looks fine in the common case of one collector that never leaves. It only shows up once a screen restarts its collector, which is exactly what Compose does on every configuration change. Model state with StateFlow because new collectors are supposed to catch up. Model events with a no-replay SharedFlow because they’re not. I use this split in Mintly’s timer, where the running countdown is state that any recomposition should reflect correctly, and the end-of-session celebration is an event that should fire exactly once — never twice, never on a screen that just reopened.
// Related reading
More from the journal
The Android In-App Review API: asking for a rating without being annoying
A practical guide to Google's In-App Review API — how it actually works, where to trigger it, and why the usual 'rate us' popup is quietly hurting your Play Store rating.
Android app shortcuts and Quick Settings Tiles: logging water without opening the app
A practical guide to Android's dynamic ShortcutManager API and TileService — how to let a one-tap action skip the app entirely, with the pitfalls that trip most implementations.
Foreground service types on Android in 2026: picking the one your feature actually qualifies for
A practical guide to Android's foreground service type restrictions — dataSync, mediaPlayback, specialUse, shortService — and how to pick the right one without getting killed or rejected.