Why One-Time Events in Jetpack Compose Keep Breaking

One-time events in Jetpack Compose keep breaking because they’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 SharedFlow with no replay drops the event outright. A Channel keeps it until someone receives it. But it’s gone once received, even if the screen stops before acting on it. Google’s architecture guidance says neither one guarantees delivery. Its fix is to reduce the event to UI state. The screen reads a field like isUserLoggedIn 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’s core logic to show where it fails.

A login screen calls viewModel.login(). On success, the ViewModel tells the UI to go to Home. It works on the emulator. A user taps “Log in” on a slow network and switches apps while it loads. They come back to the login screen. The request succeeded. Nothing happened.

Version 1: SharedFlow drops what nobody hears

The most common first attempt is a SharedFlow of events.

private val _events = MutableSharedFlow<LoginEvent>()
val events = _events.asSharedFlow()

fun login() = viewModelScope.launch {
    repository.login()
    _events.emit(LoginEvent.ToHome)
}

// in the composable
LaunchedEffect(Unit) {
    viewModel.events
        .flowWithLifecycle(lifecycle)
        .collect { navController.navigate("home") }
}

A default MutableSharedFlow has replay = 0. It delivers each value only to collectors subscribed at the moment of emit(). flowWithLifecycle stops collecting when the app goes to the background. So the login finishes and ToHome is emitted. No one receives it. There’s no error. In our JVM run, a collector that subscribed after the emit got nothing.

Version 2: Channel keeps the event, then loses it differently

The next fix swaps in a Channel. It buffers, so an event sent while nobody collects waits for the next receiver.

private val _events = Channel<LoginEvent>(Channel.BUFFERED)
val events = _events.receiveAsFlow()

That fixes the background case above. In our run, a late receiver got ToHome. But a Channel hands each value out once. Receiving it removes it. If the collecting coroutine is cancelled after receiving and before navigate() 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.

The window is small. That’s why this passes manual testing. Google’s UI events guide names the risk. When the ViewModel outlives the Compose UI, these streams “don’t guarantee the delivery and processing of those events.”

Version 3: reduce one-time events in Jetpack Compose to state

The same guide gives the fix. “Handle such events immediately and reduce them to UI state.” For login, the event becomes a fact about the screen.

data class LoginUiState(
    val isLoading: Boolean = false,
    val isUserLoggedIn: Boolean = false,
)

fun login() = viewModelScope.launch {
    _uiState.update { it.copy(isLoading = true) }
    repository.login()
    _uiState.update { it.copy(isLoading = false, isUserLoggedIn = true) }
}

// in the composable
val state by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(state.isUserLoggedIn) {
    if (state.isUserLoggedIn) onUserLogIn()
}

A StateFlow always holds its latest value. When the user comes back, the screen collects again and reads isUserLoggedIn = true. 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.

The catch: the screen stays on the back stack

Version 3 works because Login isn’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. isUserLoggedIn is still true. The effect runs again and pushes Home again.

So the UI has to record that it acted. The guide’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.

LaunchedEffect(state.isUserLoggedIn) {
    if (state.isUserLoggedIn) {
        onUserLogIn()
        viewModel.onNavigatedToHome() // sets isUserLoggedIn back to false
    }
}

The flag now lives until the UI confirms it handled it. That’s the difference from a Channel. The event isn’t removed on receipt. It’s removed on completion.

Two limits are worth knowing. The state lives in the ViewModel, so process death still clears it. Route it through SavedStateHandle if that matters. And if several events can queue up, use a list in state. The guide points to the Jetsnack sample for a queue of messages.

The rule to remember

An event that must happen is state until the UI says it happened.

  • SharedFlow with no replay loses events that fire with no collector.
  • Channel loses events whose collector is cancelled mid-handling.
  • UI state survives both. Clear it only after the UI reports it acted.

How to answer this in an interview

  1. Start with the timing problem, not an API. The ViewModel outlives the UI. So an event can fire when nothing is collecting.
  2. Walk through the streams. SharedFlow drops it with no subscriber. Channel buffers it. But it loses it if handling is cancelled after receipt.
  3. Give the fix. Reduce the event to UI state, react in a LaunchedEffect, then tell the ViewModel it was handled. Cite Google’s UI events guide.
  4. Name the limits. Back stack re-triggering needs the handled callback. Process death needs SavedStateHandle.

Common wrong answers:

  • “Use SharedFlow with replay = 1.” Now every new collector replays the last event. Rotation navigates again.
  • “Channel is safe because it buffers.” Buffering fixes the no-collector case. It doesn’t fix cancellation after receipt.
  • “Collect with Dispatchers.Main.immediate and it’s fine.” That narrows the window. The event is still removed on receipt, not on completion.
  • “Put the event in state and you’re done.” Without the handled callback, going back re-triggers it.

Related reading. The Compose null check that didn’t save you is another Compose bug where the obvious fix is wrong. GrindLoop’s Bug-Squash track turns failure patterns like this one into live debugging drills. Each drill comes with a reviewed fix.

Failed the interview? Not the next one.

Leave a Comment