Skip to main content

Kotlin Coroutines: Safely Handling One-Off UI Events (Channel vs SharedFlow)

 It is the most persistent headache in modern Android development: handling "fire-and-forget" events. You trigger a navigation action, show a Toast, or display a Snackbar. It works perfectly until the user rotates their device or switches to Dark Mode.

Suddenly, that "Payment Successful" Snackbar pops up a second time (the "Zombie Event"). Or worse, the navigation event triggers while the app is in the background, effectively vanishing into the void, leaving the user stuck on a loading screen.

Using LiveData for events was deprecated years ago. StateFlow is for state, not events. The battleground for one-off events is now between SharedFlow and Channel. If you don’t understand the underlying mechanics of how these primitives buffer and replay data, your app likely contains race conditions waiting to happen.

Here is the architectural pattern for handling single-shot UI events that ensures they are delivered exactly once, even across configuration changes.

The Root Cause: Hot Streams vs. State

To fix the bug, we must understand the architecture gap. The fundamental issue stems from conflating UI State with UI Events.

UI State (handled by StateFlow) represents what the screen is (e.g., isLoadinglistOfItems). It is sticky. If you rotate the phone, the new Activity subscribes to the StateFlow, gets the latest cached value, and renders the screen. This is desirable behavior.

UI Events (Navigation, Snackbars) are transient. They happen once. If you treat an event like state, it becomes sticky. The user rotates the phone, the view re-subscribes, and the "Show Error" event is re-emitted.

Why SharedFlow Often Fails

Many developers migrated to SharedFlow to solve the "sticky" problem. They configure it with replay = 0.

// The common mistake
val _events = MutableSharedFlow<Event>(replay = 0)

This prevents the zombie event on rotation. However, SharedFlow is a "hot" stream. If an event is emitted while the UI is not collecting (e.g., the app is backgrounded or the View is being recreated), the event is dropped. It is lost forever.

To fix the drop, developers add a buffer or replay = 1, but this re-introduces the zombie event problem. It is a vicious cycle.

The Solution: Channels and receiveAsFlow

The cleanest solution for guaranteed, exactly-once delivery is using a Kotlin Channel exposed as a Flow.

Channel implements a queue. It buffers events when there are no subscribers. Unlike SharedFlow, which broadcasts to all active subscribers or drops data, a Channel holds the item until a consumer claims it. Once consumed, the item is removed from the buffer.

This aligns perfectly with Android's lifecycle:

  1. Buffer the event while the Activity is stopped (or rotating).
  2. Deliver the event when the Activity subscribes.
  3. Remove the event so it doesn't trigger again on the next rotation.

Step 1: Define Your Events

Use a sealed interface to strictly type your UI events. This improves maintainability and makes your when statements exhaustive.

sealed interface UiEvent {
    data class ShowSnackbar(val message: String) : UiEvent
    data class NavigateToDetails(val id: String) : UiEvent
    data object ShowLoading : UiEvent
}

Step 2: The ViewModel Implementation

In your ViewModel, use a private Channel and expose it as a Flow using receiveAsFlow().

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch

class MyViewModel : ViewModel() {

    // Capacity = Channel.BUFFERED ensures we don't suspend 
    // the sender if the UI isn't ready.
    private val _uiEvents = Channel<UiEvent>(Channel.BUFFERED)
    
    // Expose as a cold Flow
    val uiEvents = _uiEvents.receiveAsFlow()

    fun submitOrder() {
        viewModelScope.launch {
            try {
                // Simulate network work
                _uiEvents.send(UiEvent.ShowLoading)
                
                // ... perform business logic ...
                
                // Success event
                _uiEvents.send(UiEvent.ShowSnackbar("Order Placed!"))
                _uiEvents.send(UiEvent.NavigateToDetails("12345"))
            } catch (e: Exception) {
                _uiEvents.send(UiEvent.ShowSnackbar("Error: ${e.message}"))
            }
        }
    }
}

Step 3: The Activity/Fragment Consumption

The collection side is critical. You must use the repeatOnLifecycle API. This API creates a coroutine that executes a block when the lifecycle is at least in a certain state (usually STARTED) and cancels it when the lifecycle falls below that state.

This ensures you don't process navigation events when the app is in the background (which causes crashes).

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.launch

class OrderActivity : AppCompatActivity() {

    private val viewModel: MyViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_order)

        observeEvents()
    }

    private fun observeEvents() {
        // Start a coroutine in the lifecycle scope
        lifecycleScope.launch {
            // suspend until lifecycle is STARTED
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                // Collect safely
                viewModel.uiEvents.collect { event ->
                    handleEvent(event)
                }
            }
        }
    }

    private fun handleEvent(event: UiEvent) {
        when (event) {
            is UiEvent.ShowSnackbar -> {
                // Show actual snackbar
                println("Showing Snackbar: ${event.message}")
            }
            is UiEvent.NavigateToDetails -> {
                // Perform navigation
                println("Navigating to ID: ${event.id}")
            }
            UiEvent.ShowLoading -> {
                // Update loading UI
            }
        }
    }
}

Deep Dive: Why This Works

The magic lies in the interaction between receiveAsFlow and repeatOnLifecycle.

When receiveAsFlow is collected, it creates a cold flow. However, it pulls data from the underlying Channel buffer. Because channels are unicast (mostly), collecting from them consumes the element.

Here is the scenario breakdown:

  1. Event Fired in Background: The user clicks "Buy", then immediately minimizes the app. The ViewModel sends ShowSnackbar.
  2. Buffering: The Activity is STOPPEDrepeatOnLifecycle cancels the collector. The event sits in the Channel buffer. It is NOT dropped.
  3. Resuming: The user re-opens the app. Activity moves to STARTEDrepeatOnLifecycle re-launches the collection block.
  4. Delivery: The collector connects to the flow, sees the buffered item, and delivers it. The Snackbar shows.
  5. Rotation: The user rotates the phone. The Activity is destroyed and recreated. repeatOnLifecycle starts a new collection.
  6. No Zombie: Because the event was consumed in step 4, the Channel is now empty. The new Activity waits for new events. No double Snackbar.

Common Pitfalls and Edge Cases

1. The Multiple Collector Problem

Channels are generally unicast. If you have multiple collectors (e.g., two fragments listening to the same ViewModel event channel), one will get the event, and the other will not.

Constraint: Ensure your events are intended for a single consumer (the current View). If you need broadcast events (e.g., logging out triggers UI updates in 5 different fragments), SharedFlow is actually better, but it requires careful synchronization. For 99% of specific screen interactions, Channel is the correct choice.

2. Immediate Execution

If you use lifecycleScope.launchWhenStarted (which is deprecated), the coroutine pauses but does not cancel. This keeps the subscription alive but paused. This can fill up buffers if you aren't careful. Stick to repeatOnLifecycle.

3. Process Death

Note that Channel and SharedFlow live in memory. If Android kills your app process to save memory (System-initiated process death), and the user returns, the ViewModel is recreated.

Any events in the Channel buffer that were not yet consumed are lost.

If an event is critical (like a completed financial transaction ID that must be shown), it should be persisted in SavedStateHandle or a database, converting it into State rather than a transient Event.

Conclusion

The distinction between State and Events is the most crucial concept in Android UI architecture.

Use StateFlow to tell the UI what to display. Use Channel (via receiveAsFlow) to tell the UI what to do.

By combining Channel buffering with repeatOnLifecycle, you eliminate both the lost-event bug and the zombie-event bug, resulting in a robust, crash-free user experience.