StateFlow vs. SharedFlow in one mental model
Both StateFlow and SharedFlow are hot flows:
they keep emitting regardless of active collectors. The key difference:
- StateFlow: A state holder with exactly one current value.
- SharedFlow: A configurable event stream with optional replay and buffering.
You can think of StateFlow as a specialized SharedFlow with
replay = 1, a non-null value property, and strong guarantees about
conflation and distinctness. SharedFlow is the more general primitive that can
model events, channels, and even broadcast queues.
StateFlow: single source of truth for UI state
StateFlow is ideal when you need a single, always-available snapshot of state:
UI models, form data, or derived domain state. It is eager:
it always has a value and never completes.
Key properties
- Always has a value: exposed via
stateFlow.value. - Conflated: fast updates collapse into the latest value.
- Distinct by reference: re-emitting the same instance still triggers collectors.
- Hot: producers run regardless of collectors.
data class UiState(
val isLoading: Boolean = false,
val items: List<Item> = emptyList(),
val error: String? = null
)
class ItemsViewModel(
private val repository: ItemsRepository
) : ViewModel() {
private val _state = MutableStateFlow(UiState())
val state: StateFlow<UiState> = _state
init {
observeItems()
}
private fun observeItems() {
viewModelScope.launch {
repository.itemsFlow()
.onStart { _state.update { it.copy(isLoading = true) } }
.catch { e ->
_state.update {
it.copy(
isLoading = false,
error = e.message ?: "Unknown error"
)
}
}
.collect { items ->
_state.update {
it.copy(
isLoading = false,
items = items,
error = null
)
}
}
}
}
fun refresh() {
viewModelScope.launch {
repository.refresh()
}
}
}
Advanced patterns with StateFlow
-
Derived state: use
map+stateInto create computedStateFlows from other flows. -
Scoped sharing: use
stateIn(scope, started, initialValue)to convert cold flows into hot, lifecycle-aware state. - Backpressure-friendly UI: because it is conflated, rapid upstream updates won’t overwhelm the UI.
class DashboardViewModel(
private val repository: DashboardRepository
) : ViewModel() {
private val rawMetrics: Flow<Metrics> =
repository.metricsStream() // cold flow from data layer
val uiState: StateFlow<DashboardUiState> =
rawMetrics
.map { metrics -> DashboardUiState.from(metrics) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = DashboardUiState.Loading
)
}
Tip: prefer stateIn over manually wiring a
MutableStateFlow when you’re just adapting an existing flow into UI state.
SharedFlow: events, broadcasts, and custom buffers
SharedFlow is a general-purpose hot flow that can replay values to new
collectors and buffer emissions. It is perfect for one-off events
(navigation, toasts, dialogs) and for multi-consumer streams
where you need more control than StateFlow offers.
Key configuration knobs
- replay: how many past values new collectors receive.
- extraBufferCapacity: how many values can be buffered beyond replay.
-
onBufferOverflow: what to do when the buffer is full
(
DROP_OLDEST,DROP_LATEST,SUSPEND).
sealed interface UiEvent {
data class ShowSnackbar(val message: String) : UiEvent
data object NavigateToLogin : UiEvent
}
class AuthViewModel(
private val authRepository: AuthRepository
) : ViewModel() {
private val _events = MutableSharedFlow<UiEvent>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val events: SharedFlow<UiEvent> = _events
fun onLogoutClicked() {
viewModelScope.launch {
authRepository.logout()
_events.emit(UiEvent.NavigateToLogin)
}
}
fun onLoginFailed(message: String) {
viewModelScope.tryEmit(UiEvent.ShowSnackbar(message))
}
}
Here, replay = 0 ensures events are not re-delivered to new collectors
(no “stale navigation” after rotation), while extraBufferCapacity = 1 allows
a single event to be buffered if the collector is briefly busy.
SharedFlow as a multi-consumer bus
Because SharedFlow supports multiple collectors, it can act as a lightweight
event bus inside a feature module—without the global, hard-to-test downsides of a
traditional event bus.
class AnalyticsBus {
private val _events = MutableSharedFlow<AnalyticsEvent>(
replay = 0,
extraBufferCapacity = 64,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val events: SharedFlow<AnalyticsEvent> = _events
fun track(event: AnalyticsEvent) {
_events.tryEmit(event)
}
}
// Consumers (e.g., in a singleton or feature scope)
class AnalyticsLogger(bus: AnalyticsBus, scope: CoroutineScope) {
init {
scope.launch {
bus.events.collect { event ->
logToBackend(event)
}
}
}
}
Warning: avoid using a global SharedFlow as a cross-app event
bus; keep it scoped to a feature or module to preserve testability and separation of concerns.
shareIn vs. stateIn: making cold flows hot
Both shareIn and stateIn convert a cold Flow into a
hot flow tied to a coroutine scope. The difference is the resulting type and semantics:
- stateIn: produces a
StateFlowwith a single current value. - shareIn: produces a
SharedFlowwith configurable replay and buffering.
class PricesRepository(
private val api: PricesApi,
externalScope: CoroutineScope
) {
// Cold flow: each collector would trigger its own network stream
private val rawPrices: Flow<PriceUpdate> =
api.pricesStream()
// Hot shared flow: single upstream subscription, multiple collectors
val sharedPrices: SharedFlow<PriceUpdate> =
rawPrices.shareIn(
scope = externalScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 10_000),
replay = 0
)
}
Use stateIn when you care about the latest value as state; use
shareIn when you care about the stream of events and want to control replay
and buffering.
When to use StateFlow vs. SharedFlow
Rule of thumb
- Use StateFlow for: long-lived, observable state that always has a value (UI models, feature state).
- Use SharedFlow for: one-off events, multi-consumer streams, or when you need custom replay/buffer behavior.
Common pitfalls
-
Pitfall: using
StateFlowfor navigation or snackbars. New collectors will immediately see the “last event” as if it just happened. -
Pitfall: using
SharedFlowwithreplay > 0for events without understanding that new collectors will re-consume old events. -
Subtle bug: relying on
StateFlowto filter out identical values. It does not performdistinctUntilChangedby default.
data class CounterState(
val value: Int = 0
)
sealed interface CounterEvent {
data class ShowToast(val message: String) : CounterEvent
}
class CounterViewModel : ViewModel() {
private val _state = MutableStateFlow(CounterState())
val state: StateFlow<CounterState> = _state
private val _events = MutableSharedFlow<CounterEvent>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val events: SharedFlow<CounterEvent> = _events
fun increment() {
val newValue = _state.value.value + 1
_state.value = CounterState(newValue)
if (newValue % 10 == 0) {
_events.tryEmit(
CounterEvent.ShowToast("Nice! You reached $newValue.")
)
}
}
}
This pattern—StateFlow for state + SharedFlow for events—is a solid default for MVVM/MVI architectures.
Testing, concurrency, and performance notes
Testing flows
-
StateFlow: you can assert on
state.valuedirectly, or collect usingrunTestandfirst()/take(). -
SharedFlow: use
replayin tests or libraries like Turbine to assert on emitted events.
Concurrency and performance
-
StateFlow updates: prefer
update { }to avoid race conditions when multiple coroutines modify the same state. -
SharedFlow buffers: choose
onBufferOverflowcarefully;SUSPENDcan backpressure producers, whileDROP_OLDESTorDROP_LATESTcan lose data. -
Hot flows and leaks: always tie
shareIn/stateInto a well-scopedCoroutineScope(e.g., ViewModel or feature scope).
Rule: if you can phrase it as “what is the current state?”, use
StateFlow. If you phrase it as “what happened?”, use SharedFlow.