This ViewModel combines a user flow and a settings flow into one UI state. A unit test reads
uiState.valueright after creating the ViewModel. It gets the empty placeholder, not the combined state. In another test, the ViewModel won’t even run without extra setup. What’s wrong?
val uiState: StateFlow<UiState> = combine(userFlow, settingsFlow) { user, settings ->
UiState(user.name, settings.darkMode)
}.stateIn(
scope = CoroutineScope(Dispatchers.Main),
started = SharingStarted.Lazily,
initialValue = UiState("", false)
)
This StateFlow stale data bug has two causes. Neither is in the combine lambda. First, SharingStarted.Lazily doesn’t start the upstream until something subscribes. Code that reads .value before any subscriber gets the initialValue. Here that’s an empty placeholder. Second, the scope is created inside the class with Dispatchers.Main hardcoded. Local unit tests don’t have a Main dispatcher unless the test replaces it. The scope also isn’t tied to the ViewModel’s lifecycle. The fix is to inject the scope, or use viewModelScope with a test rule for Main. Then pick the sharing strategy based on how the state is read. Use Eagerly when code reads .value directly and needs it current. Use WhileSubscribed when only the UI collects it.
Bug 1: Lazily waits for a subscriber, so StateFlow stale data appears
The kotlinx.coroutines docs for SharingStarted.Lazily are short. Sharing starts when the first subscriber appears and never stops. Until then, combine hasn’t collected anything. A read of uiState.value in that window returns UiState("", false).
In an app, the UI usually subscribes quickly, so this can go unnoticed. It shows up in tests that assert on .value without collecting. It also shows up in any code that reads .value before the screen starts collecting. A click handler is a common example.
Bug 2: the scope is hardcoded to Dispatchers.Main
CoroutineScope(Dispatchers.Main) is built inside the class. Android’s guide to testing coroutines covers this. In local unit tests, the Main dispatcher should be replaced with a TestDispatcher. It recommends Dispatchers.setMain, usually through a reusable JUnit rule. A class that builds its own Main scope forces every test to do that. Nothing cancels that scope when the ViewModel is cleared, so the sharing coroutine outlives the screen.
The fix
class ProfileViewModel(
userFlow: Flow<User>,
settingsFlow: Flow<Settings>,
scope: CoroutineScope // injected
) {
val uiState: StateFlow<UiState> = combine(userFlow, settingsFlow) { user, settings ->
UiState(user.name, settings.darkMode)
}.stateIn(scope, SharingStarted.Eagerly, UiState("", false))
}
Injecting the scope lets a test pass its own dispatcher and control time. In a real ViewModel, viewModelScope does the same job, as long as tests replace Main with a rule. Eagerly starts collecting right away, so .value reflects the latest combined state. Even so, a test using a StandardTestDispatcher must still advance the scheduler before asserting.
Choosing a sharing strategy
Eagerlystarts immediately and keeps running. Use it when code reads.valueand needs it current.Lazilystarts on the first subscriber and keeps running. Use it when nothing reads.valuebefore the UI collects.WhileSubscribed(timeout)stops when nobody has been listening for the timeout. Use it for expensive upstream work with UI-only collectors.
An interviewer may push on the WhileSubscribed timeout. Our post on the senior Android system design round shows how that conversation can go.
How to answer this in an interview
- Don’t start with the
combinelambda. Say you’re checking the scope and the sharing strategy first. - Name the two bugs separately. One is a sharing strategy that doesn’t match how
.valueis read. The other is a hardcoded, untestable scope. - Explain each
SharingStartedoption by the problem it solves, not only by what it does.
Common wrong answers:
- Changing the test’s assertion instead of its setup. The test is right. The ViewModel is wrong.
- Assuming
Lazilyis safe because something will subscribe eventually. That fails when code reads.valuefirst. - Keeping
Dispatchers.Mainhardcoded. That’s what makes the class hard to test, not the flow logic.
GrindLoop’s Bug-Squash track turns failure patterns like this one into live debugging drills, each with a reviewed fix.
Failed the interview? Not the next one.
4 thoughts on “Why Your Combined StateFlow Shows Stale Data: SharingStarted.Lazily vs Eagerly”