{"id":763,"date":"2026-10-06T14:05:00","date_gmt":"2026-10-06T12:05:00","guid":{"rendered":"https:\/\/grindloop.io\/blog\/?p=763"},"modified":"2026-10-05T14:06:06","modified_gmt":"2026-10-05T12:06:06","slug":"stateflow-not-emitting-mutablelist","status":"publish","type":"post","link":"https:\/\/grindloop.ai\/blog\/stateflow-not-emitting-mutablelist\/","title":{"rendered":"StateFlow Not Emitting When You Add to a MutableList Inside update {}"},"content":{"rendered":"<p>StateFlow not emitting after you add to a list means the new state equals the old one. Two causes stack up. First, <code>MutableStateFlow<\/code> compares states with <code>equals()<\/code>, not by reference. Your code adds the item to a <code>MutableList<\/code> in place. Then it calls <code>copy()<\/code>. But <code>copy()<\/code> is shallow, so the old state and the new state share one list. Both now hold the new item. So they compare equal. StateFlow skips the emission. Second, <code>update {}<\/code> is a retry loop. Under contention it can run your lambda more than once. A lambda that mutates a shared list adds the item again on every retry. The list isn&#8217;t thread-safe either. So you get duplicates, lost items and even null entries. The fix is an immutable state. Declare <code>val items: List&lt;CartItem&gt;<\/code> and build a new list inside <code>update<\/code>, with <code>state.copy(items = state.items + item)<\/code>. The lambda then has no side effects. Every add emits. A retry is harmless.<\/p>\n\n<h2>A cart that shows the headphones but never the free sticker<\/h2>\n\n<p>The running example is a shopping cart ViewModel. It&#8217;s the shape you&#8217;d write in a live-coding round. The state is a data class with a list and a total. <code>add()<\/code> uses <code>update<\/code>, because that&#8217;s the thread-safe way to change a <code>MutableStateFlow<\/code>. To compile it outside Android, <code>ViewModel<\/code> is a plain stand-in class. Nothing in the bug depends on <code>androidx.lifecycle<\/code>.<\/p>\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=\"\">import kotlinx.coroutines.*\nimport kotlinx.coroutines.flow.*\n\nopen class ViewModel \/\/ stand-in for androidx.lifecycle.ViewModel\n\ndata class CartItem(val sku: String, val priceCents: Int)\n\ndata class CartUiState(\n    val items: MutableList&lt;CartItem&gt; = mutableListOf(),\n    val totalCents: Int = 0,\n)\n\nclass CartViewModel : ViewModel() {\n    private val _uiState = MutableStateFlow(CartUiState())\n    val uiState: StateFlow&lt;CartUiState&gt; = _uiState.asStateFlow()\n\n    fun add(item: CartItem) {\n        _uiState.update { state -&gt;\n            state.items.add(item)\n            state.copy(items = state.items, totalCents = state.totalCents + item.priceCents)\n        }\n    }\n}\n\nfun main() = runBlocking {\n    val vm = CartViewModel()\n    val first = vm.uiState.value\n    val job = launch(Dispatchers.Unconfined) {\n        vm.uiState.collect { println(\"emitted: ${it.items.map { i -&gt; i.sku }} total=${it.totalCents}\") }\n    }\n    vm.add(CartItem(\"headphones\", 4999))\n    vm.add(CartItem(\"free-sticker\", 0))\n    println(\"state now: ${vm.uiState.value.items.map { it.sku }}\")\n    println(\"initial state object now holds: ${first.items.map { it.sku }}\")\n    job.cancel()\n}<\/pre>\n\n\n<p>Compiled with <code>kotlinc<\/code> 2.4.20 against kotlinx.coroutines 1.11.0, it prints this:<\/p>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">emitted: [] total=0\nemitted: [headphones] total=4999\nstate now: [headphones, free-sticker]\ninitial state object now holds: [headphones, free-sticker]<\/pre>\n\n\n<p>The headphones emit. The free sticker never does. Yet the state holds it. A screen collecting this flow would show one item while the ViewModel holds two. The bug looks random in a real app. Any add that changes the total emits. An add with a price of zero doesn&#8217;t. The last line explains it. The very first state object, the one that emitted an empty list, now holds both items.<\/p>\n\n<h2>StateFlow not emitting comes from equals and one shared list<\/h2>\n\n<p>The <a href=\"https:\/\/kotlinlang.org\/api\/kotlinx.coroutines\/kotlinx-coroutines-core\/kotlinx.coroutines.flow\/-state-flow\/\" target=\"_blank\" rel=\"noopener\">StateFlow API docs<\/a> describe the rule. Values are conflated with an <code>Any.equals<\/code> comparison, much like <code>distinctUntilChanged<\/code>. A new value equal to the previous one isn&#8217;t emitted to collectors. The source shows where it happens. In <a href=\"https:\/\/github.com\/Kotlin\/kotlinx.coroutines\/blob\/master\/kotlinx-coroutines-core\/common\/src\/flow\/StateFlow.kt\" target=\"_blank\" rel=\"noopener\"><code>StateFlow.kt<\/code><\/a>, <code>updateState<\/code> checks <code>oldState == newState<\/code>. If that&#8217;s true, it returns without storing or notifying anything.<\/p>\n\n<p>So the question is why the old and new states are equal. The <a href=\"https:\/\/kotlinlang.org\/docs\/data-classes.html\" target=\"_blank\" rel=\"noopener\">Kotlin data class docs<\/a> answer it. <code>copy()<\/code> makes a shallow copy. It doesn&#8217;t copy properties recursively, so references to other objects are shared. Walk the free-sticker call through that:<\/p>\n\n<ol>\n<li><code>update<\/code> reads the current state. Call it S1. Its <code>items<\/code> is list L, holding the headphones.<\/li>\n<li>The lambda adds the sticker to L. S1 changes in place. Its list now has two items.<\/li>\n<li><code>copy()<\/code> builds S2 with the same list L and the same total, 4999.<\/li>\n<li>Data class <code>equals()<\/code> compares S1 and S2 field by field. Both point at L. Both totals are 4999. So S1 equals S2. StateFlow skips the emission.<\/li>\n<\/ol>\n\n<p>The headphones emitted only because the total changed from 0 to 4999. Even there, the two lists compared equal. The previously emitted state had already gained the item in step 2. That&#8217;s also why the first state object ends up with both items. Every state this ViewModel ever produced points at the same list.<\/p>\n\n<h2>update {} can run your lambda more than once<\/h2>\n\n<p>The second cause stays hidden until two threads add at once. <code>update<\/code> isn&#8217;t a lock. The <a href=\"https:\/\/kotlinlang.org\/api\/kotlinx.coroutines\/kotlinx-coroutines-core\/kotlinx.coroutines.flow\/update.html\" target=\"_blank\" rel=\"noopener\"><code>update<\/code> docs<\/a> say it changes the value atomically. They also warn that the function may be evaluated multiple times if the value is being updated concurrently. The source shows why. <code>update<\/code> is a <code>while (true)<\/code> loop. It reads the value and runs your function. Then it tries <code>compareAndSet<\/code> from the value it read to the new one. If another thread changed the value in between, the swap fails and the loop runs your function again.<\/p>\n\n<p>A retry is safe only when the function has no side effects. This lambda has one. It adds to a list that every state shares. Here&#8217;s the same <code>CartViewModel<\/code> hit by 8 coroutines on <code>Dispatchers.Default<\/code>, each adding 10,000 items at 100 cents:<\/p>\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=\"\">fun main() = runBlocking {\n    val vm = CartViewModel()\n    withContext(Dispatchers.Default) {\n        repeat(8) { t -&gt;\n            launch { repeat(10_000) { i -&gt; vm.add(CartItem(\"sku-$t-$i\", 100)) } }\n        }\n    }\n    val state = vm.uiState.value\n    val distinct = state.items.filterNotNull().map { it.sku }.toSet().size\n    println(\"distinct=$distinct items=${state.items.size} nulls=${state.items.count { it == null }} total=${state.totalCents}\")\n}<\/pre>\n\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">distinct=76320 items=146543 nulls=241 total=8000000\ndistinct=77097 items=135089 nulls=134 total=8000000\ndistinct=76230 items=141992 nulls=32 total=8000000<\/pre>\n\n\n<p>Those are three runs. The exact numbers change every time. The total is right in all three, at 80,000 adds times 100 cents. The total is computed from the state the lambda receives, so a retry recomputes it correctly. The list is wrong in three ways. It holds far more than 80,000 entries, because every retry added its item again. Fewer than 80,000 SKUs are distinct, so some adds were lost. The <a href=\"https:\/\/docs.oracle.com\/en\/java\/javase\/21\/docs\/api\/java.base\/java\/util\/ArrayList.html\" target=\"_blank\" rel=\"noopener\"><code>ArrayList<\/code> docs<\/a> say the class isn&#8217;t synchronized. Concurrent <code>add<\/code> calls overwrite each other. The list is declared <code>MutableList&lt;CartItem&gt;<\/code>, yet it holds nulls. That&#8217;s a sign the concurrent adds corrupted its internal array.<\/p>\n\n<p>The two causes interact. Without the total, every state would compare equal. Then <code>compareAndSet<\/code> never fails. Nothing retries. You&#8217;d still lose items to the <code>ArrayList<\/code> race. The duplicates appear once some field changes on every add.<\/p>\n\n<h2>Build a new list inside update and keep the lambda pure<\/h2>\n\n<p>The fix removes the shared mutable list. The state holds a read-only <code>List<\/code>. The lambda builds the next state from the current one and touches nothing else.<\/p>\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=\"\">data class CartUiState(\n    val items: List&lt;CartItem&gt; = emptyList(),\n    val totalCents: Int = 0,\n)\n\nclass CartViewModel : ViewModel() {\n    private val _uiState = MutableStateFlow(CartUiState())\n    val uiState: StateFlow&lt;CartUiState&gt; = _uiState.asStateFlow()\n\n    fun add(item: CartItem) {\n        _uiState.update { state -&gt;\n            state.copy(items = state.items + item, totalCents = state.totalCents + item.priceCents)\n        }\n    }\n}<\/pre>\n\n\n<p>With the same imports and <code>main<\/code>, the output changes:<\/p>\n\n\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">emitted: [] total=0\nemitted: [headphones] total=4999\nemitted: [headphones, free-sticker] total=4999\nstate now: [headphones, free-sticker]\ninitial state object now holds: []<\/pre>\n\n\n<p>The sticker emits. The first state still holds an empty list. <code>state.items + item<\/code> returns a new list. The <a href=\"https:\/\/kotlinlang.org\/api\/core\/kotlin-stdlib\/kotlin.collections\/plus.html\" target=\"_blank\" rel=\"noopener\">stdlib <code>plus<\/code> docs<\/a> describe it as a list of the original elements followed by the new one. So the old state keeps its old list. The two states differ. In the contended test, 8 coroutines added 2,000 items each. Every run ended with exactly 16,000 items and the right total. A counter inside the lambda showed between 66,000 and 86,000 calls across three runs. So <code>update<\/code> retried tens of thousands of times. Each retry threw away a list it had built. Nothing else changed, so the retries were harmless.<\/p>\n\n<p>Two details make this hold up. First, the type. <code>List&lt;CartItem&gt;<\/code> has no <code>add<\/code>, so nobody can mutate a state by accident. Second, the cost. Each add copies the list. For a cart, that&#8217;s nothing. For a list of thousands of rows updated many times a second, the copy can start to matter. Measure it before you change the data structure.<\/p>\n\n<p>Compose sits downstream of all this. <code>collectAsStateWithLifecycle()<\/code> can only show what the flow emits. The <a href=\"https:\/\/developer.android.com\/develop\/ui\/compose\/state\" target=\"_blank\" rel=\"noopener\">Compose state guide<\/a> warns against <code>ArrayList<\/code> or <code>mutableListOf()<\/code> as state. Users see stale or incorrect data. It recommends an observable holder with an immutable <code>listOf()<\/code>. The fixed <code>CartUiState<\/code> follows that rule.<\/p>\n\n<h2>What to say when the interviewer asks why the sticker is missing<\/h2>\n\n<p>This snippet fits a live-coding round or a bug-squash round. The race is the classic second bug, the one left after the first fix goes green. Our <a href=\"https:\/\/grindloop.ai\/blog\/android-debugging-interview-second-bug\/\">Android debugging interview post<\/a> covers how that round is scored. Here&#8217;s the explanation to give.<\/p>\n\n<ol>\n<li>Name the comparison. StateFlow drops a value that <code>equals()<\/code> the current one. It doesn&#8217;t compare references. So a new <code>CartUiState<\/code> object isn&#8217;t enough.<\/li>\n<li>Explain the equality. <code>copy()<\/code> is shallow. The old and new states share one list. The in-place <code>add<\/code> already changed the old state. When the total doesn&#8217;t move, the two are equal and nothing emits.<\/li>\n<li>Name the second bug. <code>update<\/code> is a compare-and-set loop that may rerun its lambda. A side effect in it repeats on each retry. <code>ArrayList<\/code> isn&#8217;t thread-safe either. So concurrent adds produce duplicates, lost items and nulls.<\/li>\n<li>Give the fix. Use an immutable <code>List<\/code> in the state. Build the next list inside <code>update<\/code> with <code>state.items + item<\/code>. The lambda becomes a pure function of the current state, so retries are safe.<\/li>\n<\/ol>\n\n<p>The missing emission is the bug you can see on screen. The retry inside <code>update<\/code> only shows up under load, so naming it unprompted is the stronger signal. The same pipeline has another quiet failure, in how a derived flow is shared. See <a href=\"https:\/\/grindloop.ai\/blog\/why-your-combined-stateflow-shows-stale-data-sharingstarted-lazily-vs-eagerly\/\">why a combined StateFlow shows stale data<\/a>.<\/p>\n\n<h2>Three fixes candidates reach for that leave a bug behind<\/h2>\n\n<p>Each of these ran against the cart example with the same compiler and library versions.<\/p>\n\n<ul>\n<li>Switching to <code>MutableSharedFlow(replay = 1)<\/code> removes the equality check. So the free sticker does emit. But the list is still shared. After the second add, the first emitted state held both items. Any code comparing an old state with a new one sees no difference. You also give up <code>update<\/code> and its atomic swap.<\/li>\n<li>Wrapping <code>update<\/code> in a <code>Mutex<\/code> stops the race. No retry ever happens. The emission bug stays. The run printed the headphones and never the sticker.<\/li>\n<li>Building a new list but assigning it with <code>_uiState.value = _uiState.value.copy(...)<\/code> fixes the emission. This emits every add on one thread. But it reads and writes the value in two steps. We ran it twice with 8 coroutines adding 2,000 items each. The cart ended with 3,719 and then 3,998 items out of 16,000. The other adds were overwritten. That&#8217;s the race <code>update<\/code> exists to prevent.<\/li>\n<\/ul>\n\n<p>Each of these leaves one of the two causes in place. The fix needs both halves. The state must be impossible to change in place. The change must go through <code>update<\/code>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>StateFlow not emitting after you add to a MutableList? copy() shares the list so states compare equal, and update {} retries repeat the add. Repro and fix.<\/p>\n","protected":false},"author":2,"featured_media":765,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"StateFlow Not Emitting When You Add to a MutableList Inside update {}","rank_math_description":"StateFlow not emitting after you add to a MutableList? copy() shares the list so states compare equal, and update {} retries repeat the add. Repro and fix.","rank_math_focus_keyword":"stateflow not emitting","footnotes":""},"categories":[9,3],"tags":[15,70,13,16,12,14,45],"class_list":["post-763","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-bug-squash","category-coroutines","tag-android","tag-concurrency","tag-coroutines","tag-interview-prep","tag-kotlin","tag-stateflow","tag-technical-interview"],"_links":{"self":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/763","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=763"}],"version-history":[{"count":1,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/763\/revisions"}],"predecessor-version":[{"id":764,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/763\/revisions\/764"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/media\/765"}],"wp:attachment":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/media?parent=763"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/categories?post=763"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/tags?post=763"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}