Mastering Screen Lifecycle in Jetpack Compose Navigation

When developing with Jetpack Compose, a common challenge is detecting when a screen becomes visible (to refresh data) or when it moves to the background (to pause a video).

While Compose provides onDispose, this only triggers when a Composable is completely removed from the UI tree. It cannot detect when a screen is still in the backstack but no longer visible to the user.

In this post, we’ll explore how to leverage the fact that Navigation Compose uses NavBackStackEntry as a LifecycleOwner to perfectly manage screen-level events.

 

🧑🏻‍💻 1. The Core: Who is the LocalLifecycleOwner?

In Compose, you can access the current lifecycle via LocalLifecycleOwner.current. However, its identity changes depending on your app's architecture:

  • Directly under an Activity: LifecycleOwner = Activity
  • Using Navigation Compose: LifecycleOwner = NavBackStackEntry

When using Navigation Compose, each destination is wrapped in a NavBackStackEntry. When you navigate from Screen A to Screen B, the Activity remains RESUMED, but Screen A’s NavBackStackEntry transitions to the STOPPED state.

By monitoring this, you can capture lifecycle events specific to that individual screen.

 

🧑🏻‍💻 2. The Solution: Implementing ScreenLifecycleObserver

To make this reusable, we can create a custom Composable function that observes these state changes safely.

 

🧑🏻‍💻 3. Real-World Patterns

A. Refreshing Data on Screen Return

Using LaunchedEffect(Unit) only runs once when the screen is first created. If you want to refresh data every time a user navigates back to the screen, use ON_RESUME.

B. Pausing and Resuming Video

Automatically pause video when the user navigates away or minimizes the app, and resume it when they return.

C. Tracking Screen Time (Analytics)

Start a timer on ON_RESUME and send the duration on ON_PAUSE.

 

🧑🏻‍💻 Summary: When to Use What?

Finally, let's distinguish between onDispose and Lifecycle events:

If you are using Navigation Compose, LocalLifecycleOwner.current is a powerful tool. Using it correctly ensures a robust app that respects system resources and provides a seamless user experience.

I hope this guide helps you manage screen lifecycles in your Compose projects!


Ending the Event Management Debate in ViewModel: The "MVI-style" Best Practice using StateFlow and Channel

 

🧑🏻‍💻 Introduction

When streaming data from a ViewModel to the UI, do you ever struggle with how to handle one-time events like "screen navigation" or "showing a Toast"?

It’s tempting to think, "Why not just combine everything into one state?" However, this often leads to a common pitfall: event re-emission bugs.

Today, I’ll introduce a robust, boilerplate-friendly design pattern: "State = combine / Effect = merge."

 

🧑🏻‍💻 1. Separating State from Effect

First, let’s categorize UI elements into two distinct types based on their behavior:

  • UiState (State): Represents the current look of the screen. It must always hold a "latest value" (e.g., loading flags, usernames, input fields).
  • UiEffect (Side Effect): Represents momentary occurrences. These should be processed once and then forgotten (e.g., navigation, error alerts, snackbars).

 

🧑🏻‍💻 2. ViewModel Implementation: Choosing between combine and merge

In the ViewModel, we use different operators depending on the nature of the data flow.


class UserProfileViewModel(private val repository: UserRepository) : ViewModel() {

    // --- [State] Synthesizing the latest state ---
    // We combine multiple sources (Loading, User data, etc.) 
    // to ensure the UI always has a consistent "single frame" of data.
    private val _isLoading = MutableStateFlow(false)
    val uiState: StateFlow<UserProfileState> = combine(
        _isLoading, 
        repository.userData // Flow<User>
    ) { loading, user ->
        UserProfileState(userName = user.name, isLoading = loading)
    }.stateIn(
        scope = viewModelScope, 
        started = SharingStarted.WhileSubscribed(5000), 
        initialValue = UserProfileState()
    )

    // --- [Effect] Integrating independent events ---
    // Use a Channel for one-shot events and merge them into a single flow 
    // to pipe everything through a single "event bus" to the UI.
    private val navigationEvents = Channel<UserProfileEffect.Navigate>()
    private val toastEvents = Channel<UserProfileEffect.ShowToast>()

    val uiEffect: Flow<UserProfileEffect> = merge(
        navigationEvents.receiveAsFlow(),
        toastEvents.receiveAsFlow()
    )

    fun onUpdateClick() {
        viewModelScope.launch {
            _isLoading.value = true
            if (repository.update()) {
                navigationEvents.send(UserProfileEffect.Navigate("home"))
            } else {
                toastEvents.send(UserProfileEffect.ShowToast("Update failed"))
            }
            _isLoading.value = false
        }
    }
}

Why differentiate them?

  • Why combine for State? The UI must always be consistent. Even if only one value changes, combine re-emits the set of all "latest values," preventing the UI from showing incomplete data.
  • Why merge for Effect? If you use combine for events, a simple update to a State (like a loading spinner) would trigger a re-emission of the previous navigation event. merge ensures that only the event that just happened gets triggered.

 

🧑🏻‍💻 3. Handling Events in the View (Compose)

On the UI side, we handle these flows using methods tailored to their specific lifecycles.


@Composable
fun UserProfileScreen(viewModel: UserProfileViewModel) {
    // 1. Observe State: Automatically updates the UI and respects lifecycle
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    // 2. Consume Effect: Use LaunchedEffect to handle events exactly once
    LaunchedEffect(viewModel.uiEffect) {
        viewModel.uiEffect.collect { effect ->
            when (effect) {
                is UserProfileEffect.Navigate -> navController.navigate(effect.route)
                is UserProfileEffect.ShowToast -> showToast(effect.message)
            }
        }
    }

    // 3. Render UI: Simply follow the uiState
    ProfileContent(uiState) 
}

 

🧑🏻‍💻 Summary: Why this Pattern Wins

  • Unidirectional Data Flow (UDF): It clearly separates "State flowing down" from "Events flowing up."
  • Bug Prevention: It structurally prevents issues like "Toasts reappearing on screen rotation" or "double navigation."
  • Clean Code: The UI processes all events in a single when block, and the ViewModel keeps concerns neatly separated.

If you find your event management getting messy, give the State (combine) & Effect (merge) pattern a try!

👉 Android アーキテクチャの現在地:Google が推奨する UDF と、現場が選ぶ「MVI 風 MVVM」
👉 モダンUI開発の決定版: State / Effect / Event で作る「迷わない」画面実装


【Android】How to handle UI interactions with event processing

 

🧑🏻‍💻 Summary

  • UI Only
    • Good for very small, UI-contained logic.
    • No state preservation; everything resets on recomposition.
    • Simple one-time actions still work fine.
    • Weak testability and scalability.
    • Poor separation of concerns without ViewModel.
  • StateFlow
    • Designed specifically for holding and exposing UI state.
    • Always provides the latest value after recomposition.
    • Not suitable for one-time events because old values persist.
    • Ideal for continuously changing UI states.
    • Assumes at least one active collector.
    • Ensures consistent UI across configuration changes.
  • SharedFlow
    • Optimal for one-time UI events (Toast, Snackbar, Navigation).
    • replay = 0 + extraBufferCapacity = 1 helps prevent event loss.
    • Supports multiple collectors safely.
    • Not intended for state storage.
    • Standard modern pattern for UI event handling.
    • Robust against lifecycle situations where collectors appear/disappear.
  • Channel
    • Best suited for FIFO, strictly ordered event delivery.
    • Should only have one collector (shared consumption is unsafe).
    • Ensures one-by-one processing with guaranteed order.
    • Less compatible with UI lifecycle due to collector timing issues.
    • Typically replaced by SharedFlow in UI-layer logic.
    • More appropriate for internal sequential pipelines rather than UI events.

👉 【Android】UIイベント整理術:StateFlow・SharedFlow・Channelの使い分け完全ガイド
👉 Why Kotlin Channels Are the Natural Solution for Preventing "Double Execution" of Events