{"id":6,"date":"2026-09-05T01:06:53","date_gmt":"2026-09-04T23:06:53","guid":{"rendered":"https:\/\/grindloop.io\/blog\/?p=6"},"modified":"2026-09-27T00:06:34","modified_gmt":"2026-09-26T22:06:34","slug":"why-your-combined-stateflow-shows-stale-data-sharingstarted-lazily-vs-eagerly","status":"publish","type":"post","link":"https:\/\/grindloop.ai\/blog\/why-your-combined-stateflow-shows-stale-data-sharingstarted-lazily-vs-eagerly\/","title":{"rendered":"Why Your Combined StateFlow Shows Stale Data: SharingStarted.Lazily vs Eagerly"},"content":{"rendered":"<blockquote><p>This ViewModel combines a user flow and a settings flow into one UI state. A unit test reads <code>uiState.value<\/code> right after creating the ViewModel. It gets the empty placeholder, not the combined state. In another test, the ViewModel won&#8217;t even run without extra setup. What&#8217;s wrong?<\/p><\/blockquote>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">val uiState: StateFlow&lt;UiState&gt; = combine(userFlow, settingsFlow) { user, settings -&gt;\n    UiState(user.name, settings.darkMode)\n}.stateIn(\n    scope = CoroutineScope(Dispatchers.Main),\n    started = SharingStarted.Lazily,\n    initialValue = UiState(\"\", false)\n)<\/pre>\n\n\n<p>This StateFlow stale data bug has two causes. Neither is in the <code>combine<\/code> lambda. First, <code>SharingStarted.Lazily<\/code> doesn&#8217;t start the upstream until something subscribes. Code that reads <code>.value<\/code> before any subscriber gets the <code>initialValue<\/code>. Here that&#8217;s an empty placeholder. Second, the scope is created inside the class with <code>Dispatchers.Main<\/code> hardcoded. Local unit tests don&#8217;t have a Main dispatcher unless the test replaces it. The scope also isn&#8217;t tied to the ViewModel&#8217;s lifecycle. The fix is to inject the scope, or use <code>viewModelScope<\/code> with a test rule for Main. Then pick the sharing strategy based on how the state is read. Use <code>Eagerly<\/code> when code reads <code>.value<\/code> directly and needs it current. Use <code>WhileSubscribed<\/code> when only the UI collects it.<\/p>\n\n<h2>Bug 1: Lazily waits for a subscriber, so StateFlow stale data appears<\/h2>\n\n<p>The kotlinx.coroutines docs for <a href=\"https:\/\/kotlinlang.org\/api\/kotlinx.coroutines\/kotlinx-coroutines-core\/kotlinx.coroutines.flow\/-sharing-started\/-companion\/-lazily.html\" target=\"_blank\" rel=\"noopener\"><code>SharingStarted.Lazily<\/code><\/a> are short. Sharing starts when the first subscriber appears and never stops. Until then, <code>combine<\/code> hasn&#8217;t collected anything. A read of <code>uiState.value<\/code> in that window returns <code>UiState(\"\", false)<\/code>.<\/p>\n\n<p>In an app, the UI usually subscribes quickly, so this can go unnoticed. It shows up in tests that assert on <code>.value<\/code> without collecting. It also shows up in any code that reads <code>.value<\/code> before the screen starts collecting. A click handler is a common example.<\/p>\n\n<h2>Bug 2: the scope is hardcoded to Dispatchers.Main<\/h2>\n\n<p><code>CoroutineScope(Dispatchers.Main)<\/code> is built inside the class. Android&#8217;s <a href=\"https:\/\/developer.android.com\/kotlin\/coroutines\/test\" target=\"_blank\" rel=\"noopener\">guide to testing coroutines<\/a> covers this. In local unit tests, the Main dispatcher should be replaced with a <code>TestDispatcher<\/code>. It recommends <code>Dispatchers.setMain<\/code>, 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.<\/p>\n\n<h2>The fix<\/h2>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">class ProfileViewModel(\n    userFlow: Flow&lt;User&gt;,\n    settingsFlow: Flow&lt;Settings&gt;,\n    scope: CoroutineScope   \/\/ injected\n) {\n    val uiState: StateFlow&lt;UiState&gt; = combine(userFlow, settingsFlow) { user, settings -&gt;\n        UiState(user.name, settings.darkMode)\n    }.stateIn(scope, SharingStarted.Eagerly, UiState(\"\", false))\n}<\/pre>\n\n\n<p>Injecting the scope lets a test pass its own dispatcher and control time. In a real <code>ViewModel<\/code>, <code>viewModelScope<\/code> does the same job, as long as tests replace Main with a rule. <code>Eagerly<\/code> starts collecting right away, so <code>.value<\/code> reflects the latest combined state. Even so, a test using a <code>StandardTestDispatcher<\/code> must still advance the scheduler before asserting.<\/p>\n\n<h2>Choosing a sharing strategy<\/h2>\n\n<ul>\n<li><code>Eagerly<\/code> starts immediately and keeps running. Use it when code reads <code>.value<\/code> and needs it current.<\/li>\n<li><code>Lazily<\/code> starts on the first subscriber and keeps running. Use it when nothing reads <code>.value<\/code> before the UI collects.<\/li>\n<li><code>WhileSubscribed(timeout)<\/code> stops when nobody has been listening for the timeout. Use it for expensive upstream work with UI-only collectors.<\/li>\n<\/ul>\n\n<p>An interviewer may push on the <code>WhileSubscribed<\/code> timeout. Our post on the <a href=\"https:\/\/grindloop.ai\/blog\/what-the-senior-android-system-design-round-actually-tests\/\">senior Android system design round<\/a> shows how that conversation can go.<\/p>\n\n<h2>How to answer this in an interview<\/h2>\n\n<ol>\n<li>Don&#8217;t start with the <code>combine<\/code> lambda. Say you&#8217;re checking the scope and the sharing strategy first.<\/li>\n<li>Name the two bugs separately. One is a sharing strategy that doesn&#8217;t match how <code>.value<\/code> is read. The other is a hardcoded, untestable scope.<\/li>\n<li>Explain each <code>SharingStarted<\/code> option by the problem it solves, not only by what it does.<\/li>\n<\/ol>\n\n<p><strong>Common wrong answers:<\/strong><\/p>\n<ul>\n<li>Changing the test&#8217;s assertion instead of its setup. The test is right. The ViewModel is wrong.<\/li>\n<li>Assuming <code>Lazily<\/code> is safe because something will subscribe eventually. That fails when code reads <code>.value<\/code> first.<\/li>\n<li>Keeping <code>Dispatchers.Main<\/code> hardcoded. That&#8217;s what makes the class hard to test, not the flow logic.<\/li>\n<\/ul>\n\n<hr\/>\n\n<p><em>GrindLoop&#8217;s Bug-Squash track turns failure patterns like this one into live debugging drills, each with a reviewed fix.<\/em><\/p>\n\n<p><strong>Failed the interview? Not the next one.<\/strong><\/p>","protected":false},"excerpt":{"rendered":"<p>A combined StateFlow returns stale data when stateIn uses SharingStarted.Lazily and a hardcoded Main scope. The two causes, the fix, how to choose a sharing strategy and the interview answer.<\/p>\n","protected":false},"author":2,"featured_media":685,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"Combined StateFlow Showing Stale Data? Lazily vs Eagerly","rank_math_description":"A combined StateFlow shows stale data when stateIn uses the wrong SharingStarted strategy and a hardcoded scope. The two causes, the fix and the interview answer.","rank_math_focus_keyword":"stateflow stale data","footnotes":""},"categories":[9,3],"tags":[15,13,16,12,14],"class_list":["post-6","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-bug-squash","category-coroutines","tag-android","tag-coroutines","tag-interview-prep","tag-kotlin","tag-stateflow"],"_links":{"self":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/6","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/comments?post=6"}],"version-history":[{"count":5,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/6\/revisions"}],"predecessor-version":[{"id":684,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/6\/revisions\/684"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/media\/685"}],"wp:attachment":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/media?parent=6"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/categories?post=6"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/tags?post=6"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}