Kotlin Flow and StateFlow: lifecycle-aware state exposure
StateFlow is the durable way to expose observable UI state from a ViewModel: a hot, always-has-a-value flow. The two failure modes are leaking work into the background (non-lifecycle-aware collection) and untestable code (hardcoded dispatchers). This guideline encodes the patterns that avoid both, current as of androidx.lifecycle 2.8+ (2024–2026).
Exposing state
- The ViewModel MUST expose read-only
StateFlow<UiState>(orSharedFlowfor one-shot events), never a mutableMutableStateFlowdirectly. Back it with a privateMutableStateFlowand expose.asStateFlow(). - When deriving state from a cold upstream (Room, DataStore, repository flow), you SHOULD convert with
stateIn(scope, started, initialValue)rather than manually collecting into aMutableStateFlow.stateIngives the production pipeline lifecycle control tied to subscription. - The
startedpolicy SHOULD beSharingStarted.WhileSubscribed(5_000). The 5-second stop timeout keeps the upstream alive across configuration changes and short app-switches while still tearing it down when the UI is truly gone.
val uiState: StateFlow<UiState> = repository.items
.map { items -> UiState(items) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = UiState.Loading,
)
initialValueMUST be a real renderable state (e.g.Loading), becauseStateFlow.valueis read synchronously before the upstream emits.
Choosing a SharingStarted policy
| Policy | When to use |
|---|---|
WhileSubscribed(5_000) |
Default for UI state — stops upstream shortly after UI stops collecting. |
Eagerly |
Pipeline must run for the ViewModel's whole life regardless of subscribers (rare). |
Lazily |
Start on first subscriber, never stop. Use only when restart cost is unacceptable. |
Collecting lifecycle-aware (the critical part)
Collecting a StateFlow does NOT auto-stop when the UI goes to the background — unlike LiveData.observe(). You MUST collect in a lifecycle-aware way or the collector keeps running (and keeps WhileSubscribed upstream alive) while the screen is invisible.
- Compose: use
collectAsStateWithLifecycle()(fromandroidx.lifecycle:lifecycle-runtime-compose). It collects only while the lifecycle is at leastSTARTED.
val state by viewModel.uiState.collectAsStateWithLifecycle()
- Views / Fragments / Activities: collect inside
repeatOnLifecycle(Lifecycle.State.STARTED)fromlifecycleScope. The block is launched on eachSTARTEDand cancelled onSTOPPED.
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { render(it) }
}
}
Anti-patterns — flag and fix
- MUST NOT use bare
collectAsState()for ViewModel flows in lifecycle-bound UI. It collects regardless of lifecycle state, wasting CPU/network/battery while backgrounded. Replace withcollectAsStateWithLifecycle(). - MUST NOT use
lifecycleScope.launchWhenStarted/launchWhenResumed/whenStarted. These are deprecated (androidx.lifecycle 2.4+): the pausing dispatcher suspends the coroutine but leaves upstream resources allocated. Replace withrepeatOnLifecycle. - MUST NOT collect a flow directly in
lifecycleScope.launch { ... }withoutrepeatOnLifecycle; that collects through the backgrounded state.
Injecting dispatchers
- Code that switches threads SHOULD receive its dispatchers via constructor injection, not reference
Dispatchers.IO/Dispatchers.Defaultdirectly. Hardcoded dispatchers cannot be swapped for a test dispatcher, making suspend logic flaky or untestable.
class ItemRepository(private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) {
suspend fun load() = withContext(ioDispatcher) { /* blocking I/O */ }
}
- In tests, inject
StandardTestDispatcher/UnconfinedTestDispatcherand drive virtual time withrunTest. - A
StateFlowbuilt withWhileSubscribedonly starts its upstream when collected: tests MUST keep at least one active collector (e.g. collect into a job) orvaluestays atinitialValue.