{"id":772,"date":"2026-10-07T13:55:00","date_gmt":"2026-10-07T11:55:00","guid":{"rendered":"https:\/\/grindloop.io\/blog\/?p=772"},"modified":"2026-10-05T18:23:15","modified_gmt":"2026-10-05T16:23:15","slug":"launchedeffect-stale-value-callback","status":"publish","type":"post","link":"https:\/\/grindloop.ai\/blog\/launchedeffect-stale-value-callback\/","title":{"rendered":"LaunchedEffect Stale Value: Why LaunchedEffect(Unit) Calls an Old Callback"},"content":{"rendered":"<p>A LaunchedEffect stale value comes from two captures stacked on top of each other. First, <code>LaunchedEffect(Unit)<\/code> starts its coroutine once. The block keeps the <code>onTimeUp<\/code> lambda it saw in the first composition. Recomposition passes in newer lambdas. The running block never sees them. Second, the parent built that lambda as <code>{ onSubmit(answer) }<\/code>, with <code>answer<\/code> as a plain <code>String<\/code> parameter. A lambda copies a plain value when it&#8217;s created. So the first lambda holds the empty string forever. Before the state was hoisted, <code>answer<\/code> was a delegated <code>mutableStateOf<\/code>. The lambda read it on every call. That hid the first bug. The fix keeps the timer running and refreshes the callback. Read it through <code>rememberUpdatedState(onTimeUp)<\/code> inside the effect. Then key the effect on what should restart it. Here that&#8217;s the question ID. Don&#8217;t key it on the callback. A new lambda arrives with every keystroke, so the timer would restart each time.<\/p>\n\n<h2>A 60-second question timer that submits an empty answer<\/h2>\n\n<p>The running example is a timed coding question. The candidate types an answer. After 60 seconds the timer submits whatever is in the box. The screen is stateless, because the answer was hoisted to the ViewModel. Here&#8217;s the code a candidate gets handed in a bug-squash round.<\/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=\"\">@Composable\nfun QuestionTimer(seconds: Int, onTimeUp: () -&gt; Unit) {\n    LaunchedEffect(Unit) {\n        delay(seconds * 1_000L)\n        onTimeUp()\n    }\n}\n\n@Composable\nfun QuestionScreen(\n    questionId: String,\n    answer: String,\n    onAnswerChange: (String) -&gt; Unit,\n    onSubmit: (String) -&gt; Unit,\n) {\n    QuestionTimer(seconds = 60, onTimeUp = { onSubmit(answer) })\n    OutlinedTextField(value = answer, onValueChange = onAnswerChange)\n    Button(onClick = { onSubmit(answer) }) { Text(\"Submit\") }\n}<\/pre>\n\n\n<p>The candidate types &#8220;use a Set&#8221; and waits. At 60 seconds the ViewModel receives an empty string. The text field shows the answer the whole time. The Submit button sends the right text, too. Only the timer is wrong. Nothing crashes and nothing logs. So the search tends to start in the ViewModel. That&#8217;s the wrong layer.<\/p>\n\n<h2>A LaunchedEffect stale value starts with the Unit key<\/h2>\n\n<p>The <a href=\"https:\/\/developer.android.com\/develop\/ui\/compose\/side-effects\" target=\"_blank\" rel=\"noopener\">Compose side-effects guide<\/a> states the restart rule. The coroutine is canceled and relaunched only when <code>LaunchedEffect<\/code> recomposes with different keys. <code>Unit<\/code> never changes. So the block launched in the first composition is the only one that ever runs.<\/p>\n\n<p>That block is a lambda. It captured the <code>onTimeUp<\/code> parameter as it was at that moment. <code>QuestionTimer<\/code> does recompose on every keystroke. Each time, it receives a new <code>onTimeUp<\/code>. But that new value goes into a block that <code>LaunchedEffect<\/code> ignores, because the key didn&#8217;t change. The running coroutine still holds the first lambda.<\/p>\n\n<p>The Button doesn&#8217;t have this problem. <code>onClick<\/code> is replaced on every recomposition. A tap calls the current one. The timer is the only consumer that outlives a composition.<\/p>\n\n<p>The same guide warns that <code>LaunchedEffect(true)<\/code> is as suspicious as a <code>while(true)<\/code>. It also gives a rule for variables used in an effect. Each one should be a key or go through <code>rememberUpdatedState<\/code>. This code does neither.<\/p>\n\n<h2>Hoisting the answer turned a state read into a copied String<\/h2>\n\n<p>A stale first lambda alone isn&#8217;t enough to lose the answer. The lambda also has to hold an old value. Before the refactor, the screen owned its state:<\/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=\"\">var answer by rememberSaveable { mutableStateOf(\"\") }\nQuestionTimer(seconds = 60, onTimeUp = { onSubmit(answer) })<\/pre>\n\n\n<p>That version submitted the right text with the same <code>LaunchedEffect(Unit)<\/code>. <code>answer<\/code> there is a local delegated property. A lambda that uses it captures the delegate. The delegate is the <code>MutableState<\/code> object. Each read of <code>answer<\/code> calls <code>getValue<\/code> at that moment. So the first lambda still read the latest text.<\/p>\n\n<p>Hoisting changed <code>answer<\/code> into a <code>String<\/code> parameter. A lambda captures a plain <code>val<\/code> by its value. The first lambda now holds <code>\"\"<\/code>. This plain Kotlin program shows the difference. <code>FakeState<\/code> stands in for <code>MutableState<\/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 kotlin.reflect.KProperty\n\nclass FakeState(var v: String) {\n    operator fun getValue(t: Any?, p: KProperty&lt;*&gt;) = v\n    operator fun setValue(t: Any?, p: KProperty&lt;*&gt;, n: String) { v = n }\n}\n\nfun main() {\n    var answer by FakeState(\"\")   \/\/ like var answer by mutableStateOf(\"\")\n    val delegated = { answer }    \/\/ captures the delegate\n    val plain = answer            \/\/ like a hoisted answer: String parameter\n    val snapshot = { plain }      \/\/ captures the value \"\"\n    answer = \"use a Set\"\n    println(\"delegated lambda -&gt; \\\"${delegated()}\\\"\")\n    println(\"plain-val lambda -&gt; \\\"${snapshot()}\\\"\")\n}<\/pre>\n\n\n<p>Compiled with <code>kotlinc<\/code> 2.4.20, 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=\"\">delegated lambda -&gt; \"use a Set\"\nplain-val lambda -&gt; \"\"<\/pre>\n\n\n<p>The <code>Unit<\/code> key was wrong from the start. The delegated read covered for it until the refactor moved the state up a level. The same delegated read causes a different Compose bug. See <a href=\"https:\/\/grindloop.ai\/blog\/compose-null-check-crash\/\">the null check that didn&#8217;t save you<\/a>.<\/p>\n\n<h2>Why every keystroke produces a new onTimeUp<\/h2>\n\n<p>The fix depends on how often <code>onTimeUp<\/code> changes. Strong skipping mode decides that. It&#8217;s on by default from Kotlin 2.0.20, per the <a href=\"https:\/\/developer.android.com\/develop\/ui\/compose\/performance\/stability\/strongskipping\" target=\"_blank\" rel=\"noopener\">strong skipping docs<\/a>. The compiler wraps every lambda in a composable in a <code>remember<\/code>. That <code>remember<\/code> is keyed on the lambda&#8217;s captures.<\/p>\n\n<p><code>{ onSubmit(answer) }<\/code> captures <code>onSubmit<\/code> and <code>answer<\/code>. Both are keys of that hidden <code>remember<\/code>. <code>answer<\/code> changes with each keystroke. So <code>QuestionTimer<\/code> gets a new lambda each time the candidate types. A lambda built from changing state is new whenever that state changes.<\/p>\n\n<p>That rules out keying the effect on <code>onTimeUp<\/code>. We modeled the effect&#8217;s key check and the lambda memo in plain Kotlin with kotlinx.coroutines. Four keystrokes arrived 300 ms apart against a 1-second timer. With the <code>Unit<\/code> key, the model submitted <code>\"\"<\/code> at about 1,000 ms. Keyed on <code>onTimeUp<\/code>, it started the effect five times. It submitted &#8220;use a Set&#8221; at about 2,200 ms. The timer had moved its deadline with every keystroke. That model reproduces the documented rules. It isn&#8217;t Compose itself.<\/p>\n\n<h2>Key the effect on the question and read the callback through rememberUpdatedState<\/h2>\n\n<p>The fix separates two jobs. A new question should restart the timer. A new callback should only replace the one the timer will call. Each job gets its own tool.<\/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=\"\">@Composable\nfun QuestionTimer(questionId: String, seconds: Int, onTimeUp: () -&gt; Unit) {\n    val currentOnTimeUp by rememberUpdatedState(onTimeUp)\n\n    LaunchedEffect(questionId, seconds) {\n        delay(seconds * 1_000L)\n        currentOnTimeUp()\n    }\n}\n\n\/\/ in QuestionScreen\nQuestionTimer(questionId = questionId, seconds = 60, onTimeUp = { onSubmit(answer) })<\/pre>\n\n\n<p>This follows the <code>LandingScreen<\/code> sample in the <a href=\"https:\/\/developer.android.com\/develop\/ui\/compose\/side-effects\" target=\"_blank\" rel=\"noopener\">side-effects guide<\/a>. The implementation is small. In <a href=\"https:\/\/github.com\/androidx\/androidx\/blob\/androidx-main\/compose\/runtime\/runtime\/src\/commonMain\/kotlin\/androidx\/compose\/runtime\/SnapshotState.kt\" target=\"_blank\" rel=\"noopener\"><code>SnapshotState.kt<\/code><\/a>, <code>rememberUpdatedState<\/code> remembers one <code>mutableStateOf<\/code>. Then it writes the new value into it on every composition. The effect block captures that one <code>State<\/code> through the <code>by<\/code> delegate. So the call at 60 seconds reads the newest lambda. That lambda carries the newest <code>answer<\/code>.<\/p>\n\n<p>The delegated read in the old stateful screen did the same job by accident. <code>rememberUpdatedState<\/code> does it on purpose, at the place that needs it. The parent can now pass any lambda it likes, built from plain values or state.<\/p>\n\n<p>The <code>questionId<\/code> key covers a case the old code also got wrong. Suppose the same <code>QuestionScreen<\/code> stays in composition and shows question 2. With <code>Unit<\/code>, question 2 never gets its own 60 seconds. With <code>questionId<\/code>, the old coroutine is canceled and a fresh timer starts. In the model, the fixed version submitted &#8220;use&#8221; at about 1,000 ms. That was the text in the box when time ran out. The effect started once.<\/p>\n\n<p>Android and Compose APIs can&#8217;t compile outside an Android project. So this fix hasn&#8217;t been run on a device here. Its behavior rests on the guide and the runtime source above.<\/p>\n\n<h2>The explanation that gets credit in a bug-squash round<\/h2>\n\n<p>This snippet works in a technical round or a bug-squash round. It&#8217;s a good &#8220;second bug&#8221; because the obvious first fix makes it worse. Our <a href=\"https:\/\/grindloop.ai\/blog\/android-debugging-interview-second-bug\/\">Android debugging interview post<\/a> covers how that round is scored. Say it in this order.<\/p>\n\n<ol>\n<li>Locate it. The text field and the button are right, so the state is fine. Only the long-lived consumer is wrong. That&#8217;s the effect.<\/li>\n<li>Name the first capture. <code>LaunchedEffect(Unit)<\/code> launches once. Its block holds the <code>onTimeUp<\/code> from the first composition. A new value only matters when a key changes.<\/li>\n<li>Name the second capture. <code>answer<\/code> is a plain <code>String<\/code> parameter, so that first lambda copied <code>\"\"<\/code>. A delegated <code>mutableStateOf<\/code> would have been read at call time. That&#8217;s why it worked before the state was hoisted.<\/li>\n<li>Reject the obvious fix. Keying on <code>onTimeUp<\/code> restarts the timer on every keystroke. Strong skipping remembers the lambda by its captures. <code>answer<\/code> is one of them.<\/li>\n<li>Give the fix. Key the effect on <code>questionId<\/code>. Call the callback through <code>rememberUpdatedState<\/code>.<\/li>\n<\/ol>\n\n<p>&#8220;Use rememberUpdatedState&#8221; is the line people memorize. Point 3 goes further. It explains why the bug appeared only after a refactor. That shows you know what a lambda captures.<\/p>\n\n<h2>Answers that sound right and leave the timer broken<\/h2>\n\n<ul>\n<li>&#8220;Add <code>onTimeUp<\/code> to the keys.&#8221; The answer is fresh. But the timer never expires while the candidate keeps typing. A 60-second limit becomes 60 seconds after the last keystroke.<\/li>\n<li>&#8220;Key it on <code>answer<\/code>.&#8221; This fails the same way, for the same reason. It also makes the timer depend on a value it doesn&#8217;t use.<\/li>\n<li>&#8220;Wrap it in <code>remember { onTimeUp }<\/code>.&#8221; <code>remember<\/code> without keys stores the first value and returns it forever. That freezes the stale lambda in place.<\/li>\n<li>&#8220;Pass <code>answer<\/code> into <code>QuestionTimer<\/code> and submit it there.&#8221; The parameter is captured the same way. The timer also takes on a job that belongs to the screen.<\/li>\n<li>&#8220;The composable isn&#8217;t recomposing.&#8221; It is. <code>QuestionTimer<\/code> recomposes on every keystroke. The effect doesn&#8217;t restart. That&#8217;s documented behavior.<\/li>\n<\/ul>\n\n<p>Events can also get lost in a Compose effect without going stale. See <a href=\"https:\/\/grindloop.ai\/blog\/one-time-events-jetpack-compose-keep-breaking\/\">why one-time events in Jetpack Compose keep breaking<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>A LaunchedEffect stale value comes from LaunchedEffect(Unit) keeping its first lambda and a captured String. Why keying on the callback fails, and the fix.<\/p>\n","protected":false},"author":2,"featured_media":775,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"LaunchedEffect Stale Value: Why LaunchedEffect(Unit) Calls an Old Callback","rank_math_description":"A LaunchedEffect stale value comes from LaunchedEffect(Unit) keeping its first lambda and a captured String. Why keying on the callback fails, and the fix.","rank_math_focus_keyword":"launchedeffect stale value","footnotes":""},"categories":[9,2],"tags":[15,16,42,12,101,45],"class_list":["post-772","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-bug-squash","category-kotlin","tag-android","tag-interview-prep","tag-jetpack-compose","tag-kotlin","tag-side-effects","tag-technical-interview"],"_links":{"self":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/772","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=772"}],"version-history":[{"count":2,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/772\/revisions"}],"predecessor-version":[{"id":774,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/772\/revisions\/774"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/media\/775"}],"wp:attachment":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/media?parent=772"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/categories?post=772"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/tags?post=772"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}