{"id":73,"date":"2026-09-30T13:05:00","date_gmt":"2026-09-30T11:05:00","guid":{"rendered":"https:\/\/grindloop.io\/blog\/?p=73"},"modified":"2026-09-27T16:59:05","modified_gmt":"2026-09-27T14:59:05","slug":"one-time-events-jetpack-compose-keep-breaking","status":"publish","type":"post","link":"https:\/\/grindloop.ai\/blog\/one-time-events-jetpack-compose-keep-breaking\/","title":{"rendered":"Why One-Time Events in Jetpack Compose Keep Breaking"},"content":{"rendered":"<p>One-time events in Jetpack Compose keep breaking because they&#8217;re sent as signals. A signal only works if someone is listening at that exact moment. Navigation, snackbars and haptics often fire when no screen is collecting. The app might be in the background, or mid-rotation. A <code>SharedFlow<\/code> with no replay drops the event outright. A <code>Channel<\/code> keeps it until someone receives it. But it&#8217;s gone once received, even if the screen stops before acting on it. Google&#8217;s architecture guidance says neither one guarantees delivery. Its fix is to reduce the event to UI state. The screen reads a field like <code>isUserLoggedIn<\/code> and reacts to it. Any collector that attaches later still sees it. If the screen stays on the back stack, the UI also needs to record that it already acted. Below is one login screen built all three ways. We ran each version&#8217;s core logic to show where it fails.<\/p>\n\n<blockquote><p>A login screen calls <code>viewModel.login()<\/code>. On success, the ViewModel tells the UI to go to Home. It works on the emulator. A user taps &#8220;Log in&#8221; on a slow network and switches apps while it loads. They come back to the login screen. The request succeeded. Nothing happened.<\/p><\/blockquote>\n\n<h2>Version 1: SharedFlow drops what nobody hears<\/h2>\n\n<p>The most common first attempt is a <code>SharedFlow<\/code> of events.<\/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=\"\">private val _events = MutableSharedFlow&lt;LoginEvent&gt;()\nval events = _events.asSharedFlow()\n\nfun login() = viewModelScope.launch {\n    repository.login()\n    _events.emit(LoginEvent.ToHome)\n}\n\n\/\/ in the composable\nLaunchedEffect(Unit) {\n    viewModel.events\n        .flowWithLifecycle(lifecycle)\n        .collect { navController.navigate(\"home\") }\n}<\/pre>\n\n\n<p>A default <code>MutableSharedFlow<\/code> has <code>replay = 0<\/code>. It delivers each value only to collectors subscribed at the moment of <code>emit()<\/code>. <code>flowWithLifecycle<\/code> stops collecting when the app goes to the background. So the login finishes and <code>ToHome<\/code> is emitted. No one receives it. There&#8217;s no error. In our JVM run, a collector that subscribed after the emit got nothing.<\/p>\n\n<h2>Version 2: Channel keeps the event, then loses it differently<\/h2>\n\n<p>The next fix swaps in a <code>Channel<\/code>. It buffers, so an event sent while nobody collects waits for the next receiver.<\/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=\"\">private val _events = Channel&lt;LoginEvent&gt;(Channel.BUFFERED)\nval events = _events.receiveAsFlow()<\/pre>\n\n\n<p>That fixes the background case above. In our run, a late receiver got <code>ToHome<\/code>. But a <code>Channel<\/code> hands each value out once. Receiving it removes it. If the collecting coroutine is cancelled after receiving and before <code>navigate()<\/code> runs, the event is gone. The lifecycle stopping at the wrong moment does exactly that. We simulated it by cancelling the receiver mid-handling. The next collector got nothing.<\/p>\n\n<p>The window is small. That&#8217;s why this passes manual testing. Google&#8217;s <a href=\"https:\/\/developer.android.com\/topic\/architecture\/ui-layer\/events\" target=\"_blank\" rel=\"noopener\">UI events guide<\/a> names the risk. When the ViewModel outlives the Compose UI, these streams &#8220;don&#8217;t guarantee the delivery and processing of those events.&#8221;<\/p>\n\n<h2>Version 3: reduce one-time events in Jetpack Compose to state<\/h2>\n\n<p>The same guide gives the fix. &#8220;Handle such events immediately and reduce them to UI state.&#8221; For login, the event becomes a fact about the screen.<\/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 LoginUiState(\n    val isLoading: Boolean = false,\n    val isUserLoggedIn: Boolean = false,\n)\n\nfun login() = viewModelScope.launch {\n    _uiState.update { it.copy(isLoading = true) }\n    repository.login()\n    _uiState.update { it.copy(isLoading = false, isUserLoggedIn = true) }\n}\n\n\/\/ in the composable\nval state by viewModel.uiState.collectAsStateWithLifecycle()\nLaunchedEffect(state.isUserLoggedIn) {\n    if (state.isUserLoggedIn) onUserLogIn()\n}<\/pre>\n\n\n<p>A <code>StateFlow<\/code> always holds its latest value. When the user comes back, the screen collects again and reads <code>isUserLoggedIn = true<\/code>. It navigates then. Nothing depended on being subscribed at the right instant. In our run, a collector that attached after the update still saw it.<\/p>\n\n<h2>The catch: the screen stays on the back stack<\/h2>\n\n<p>Version 3 works because Login isn&#8217;t kept on the back stack. The guide points out that if screen A stays on the stack, it may keep advancing to B. Say the user goes back from Home to Login. <code>isUserLoggedIn<\/code> is still true. The effect runs again and pushes Home again.<\/p>\n\n<p>So the UI has to record that it acted. The guide&#8217;s back-stack example keeps that extra flag in the UI. The simpler version below mirrors its pattern for messages. The UI calls back once it acted. Then the ViewModel clears the 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=\"\">LaunchedEffect(state.isUserLoggedIn) {\n    if (state.isUserLoggedIn) {\n        onUserLogIn()\n        viewModel.onNavigatedToHome() \/\/ sets isUserLoggedIn back to false\n    }\n}<\/pre>\n\n\n<p>The flag now lives until the UI confirms it handled it. That&#8217;s the difference from a <code>Channel<\/code>. The event isn&#8217;t removed on receipt. It&#8217;s removed on completion.<\/p>\n\n<p>Two limits are worth knowing. The state lives in the ViewModel, so process death still clears it. Route it through <code>SavedStateHandle<\/code> if that matters. And if several events can queue up, use a list in state. The guide points to the <a href=\"https:\/\/github.com\/android\/compose-samples\/tree\/main\/Jetsnack\" target=\"_blank\" rel=\"noopener\">Jetsnack sample<\/a> for a queue of messages.<\/p>\n\n<h2>The rule to remember<\/h2>\n\n<p><strong>An event that must happen is state until the UI says it happened.<\/strong><\/p>\n\n<ul>\n<li><code>SharedFlow<\/code> with no replay loses events that fire with no collector.<\/li>\n<li><code>Channel<\/code> loses events whose collector is cancelled mid-handling.<\/li>\n<li>UI state survives both. Clear it only after the UI reports it acted.<\/li>\n<\/ul>\n\n<h2>How to answer this in an interview<\/h2>\n\n<ol>\n<li>Start with the timing problem, not an API. The ViewModel outlives the UI. So an event can fire when nothing is collecting.<\/li>\n<li>Walk through the streams. <code>SharedFlow<\/code> drops it with no subscriber. <code>Channel<\/code> buffers it. But it loses it if handling is cancelled after receipt.<\/li>\n<li>Give the fix. Reduce the event to UI state, react in a <code>LaunchedEffect<\/code>, then tell the ViewModel it was handled. Cite Google&#8217;s UI events guide.<\/li>\n<li>Name the limits. Back stack re-triggering needs the handled callback. Process death needs <code>SavedStateHandle<\/code>.<\/li>\n<\/ol>\n\n<p><strong>Common wrong answers:<\/strong><\/p>\n\n<ul>\n<li>&#8220;Use <code>SharedFlow<\/code> with <code>replay = 1<\/code>.&#8221; Now every new collector replays the last event. Rotation navigates again.<\/li>\n<li>&#8220;<code>Channel<\/code> is safe because it buffers.&#8221; Buffering fixes the no-collector case. It doesn&#8217;t fix cancellation after receipt.<\/li>\n<li>&#8220;Collect with <code>Dispatchers.Main.immediate<\/code> and it&#8217;s fine.&#8221; That narrows the window. The event is still removed on receipt, not on completion.<\/li>\n<li>&#8220;Put the event in state and you&#8217;re done.&#8221; Without the handled callback, going back re-triggers it.<\/li>\n<\/ul>\n\n<hr\/>\n\n<p><em>Related reading. <a href=\"https:\/\/grindloop.ai\/blog\/compose-null-check-crash\/\">The Compose null check that didn&#8217;t save you<\/a> is another Compose bug where the obvious fix is wrong. GrindLoop&#8217;s Bug-Squash track turns failure patterns like this one into live debugging drills. Each drill comes with a reviewed fix.<\/em><\/p>\n\n<p><strong>Failed the interview? Not the next one.<\/strong><\/p>","protected":false},"excerpt":{"rendered":"<p>SharedFlow drops one-time events with no collector, and Channel loses them on cancellation. One login screen built three ways, with Google&#8217;s fix: reduce the event to UI state.<\/p>\n","protected":false},"author":2,"featured_media":211,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"One-Time Events in Jetpack Compose Keep Breaking","rank_math_description":"One-time events in Jetpack Compose keep breaking because Channel and SharedFlow don't guarantee delivery. Here's what Google's own docs recommend instead.","rank_math_focus_keyword":"one-time events in jetpack compose","footnotes":""},"categories":[9,2],"tags":[15,16,42,12,69,14,45],"class_list":["post-73","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-sharedflow","tag-stateflow","tag-technical-interview"],"_links":{"self":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/73","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=73"}],"version-history":[{"count":14,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/73\/revisions"}],"predecessor-version":[{"id":701,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/posts\/73\/revisions\/701"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/media\/211"}],"wp:attachment":[{"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/media?parent=73"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/categories?post=73"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/grindloop.ai\/blog\/wp-json\/wp\/v2\/tags?post=73"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}