Why Your Combined StateFlow Shows Stale Data: SharingStarted.Lazily vs Eagerly

This ViewModel combines a user flow and a settings flow into one UI state. A unit test reads uiState.value right 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

  • Eagerly starts immediately and keeps running. Use it when code reads .value and needs it current.
  • Lazily starts on the first subscriber and keeps running. Use it when nothing reads .value before 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

  1. Don’t start with the combine lambda. Say you’re checking the scope and the sharing strategy first.
  2. Name the two bugs separately. One is a sharing strategy that doesn’t match how .value is read. The other is a hardcoded, untestable scope.
  3. Explain each SharingStarted option 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 Lazily is safe because something will subscribe eventually. That fails when code reads .value first.
  • Keeping Dispatchers.Main hardcoded. 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”

Leave a Comment