
Hello Folks,
Here we are again with a small topic of Jetpack-Compose. It is also a small but necessary one for new or experienced devs. We are going to talk about the execution order of Jetpack Compose side effects and composables, specifically focusing on the execution order and lifecycle interactions of DisposableEffect
, LaunchedEffect
, and composable functions.
We’ll take a closer look at how DisposableEffect
and LaunchedEffect
are executed when navigating between composables, especially focusing on their behavior when returning to a previously visited screen. (Many experience devs will tell I know that but I bet you many of you don’t).
So, let’s jump in.
@Composable fun MyComposable(cartId: String) { val lifecycleOwner = LocalLifecycleOwner.current // DisposableEffect observes the lifecycleOwner DisposableEffect(lifecycleOwner) { Log.e("Init", "DisposableEffect") onDispose { Log.e("Init", "DisposableEffect - onDispose") } } // LaunchedEffect triggers when cartId changes LaunchedEffect(key1 = cartId) { Log.e("Init", "LaunchedEffect") } // Scaffold is the UI container Column { Log.e("Init", "Column") // You can add your screen content here } } Output:- E/Init: Column E/Init: DisposableEffect E/Init: LaunchedEffect
Execution Order:-
Why does Column log first?
The answer lies in when these side-effect APIs (DisposableEffect, LaunchedEffect) are actually executed relative to composition.
1. Composition Phase Comes First
- Jetpack Compose first builds the UI tree during composition.
- At this point, Column is a composable function. It is executed immediately during the composition phase to build the UI.
- So: Column() runs first → logs “Column”.
2. Side-Effects Are Registered During Composition But Executed After
- DisposableEffect and LaunchedEffect register their work during composition, but their actual execution happens after the composition finishes.
- Compose uses an internal scheduler (via Recomposer) to run side effects after the frame is committed.
So, the real timeline looks like this:
Composition starts → Column() runs → logs "Column" → Registers DisposableEffect block → Registers LaunchedEffect block Composition ends → Side effects start → DisposableEffect executes → logs "DisposableEffect" → LaunchedEffect launches coroutine → logs "LaunchedEffect"
But here, we have talked about the Execution order between composable and Side-Effects.
But what about the SideEffects which execute first between LaunchEffect
and DisposableEffect
.
Let’s look into that.
Order of Execution (After Composition Completes):
- DisposableEffect → runs first
- LaunchedEffect → runs after
Why?
This order is defined by the Compose runtime itself:
- DisposableEffect is synchronous and meant to handle setup/cleanup immediately after composition.
- LaunchedEffect starts a coroutine, and coroutine launching is asynchronous, scheduled to run after other synchronous effects like DisposableEffect.
Internally:
Jetpack Compose maintains a well-defined order of applying effects:
- Side effects like DisposableEffect, SideEffect, SnapshotFlow, etc., are triggered immediately after composition (synchronously).
- Then coroutine-based effects like LaunchedEffect are dispatched to run next (asynchronously, via Recomposer).
Now, Let’s see how DisposableEffect
and LaunchedEffect
are executed when navigating between composables, especially focusing on their behavior when returning to a previously visited screen.
Output is going to surprise you.
@Composable fun MyApp() { val navController = rememberNavController() NavHost(navController = navController, startDestination = "screenA") { composable("screenA") { ScreenA( cartId = "123", onNavigateToB = { navController.navigate("screenB") } ) } composable("screenB") { ScreenB( cartId = "456", onNavigateBack = { navController.popBackStack() } ) } } } @Composable fun ScreenA(cartId: String, onNavigateToB: () -> Unit) { DisposableEffect(Unit) { println("😇 ScreenA -> DisposableEffect") onDispose { println("😇 ScreenA -> DisposableEffect - onDispose") } } LaunchedEffect(cartId) { println("😇ScreenA -> LaunchedEffect") } Column(modifier = Modifier.padding(top = 100.dp)) { Button(onClick = onNavigateToB) { Text(text = "Navigate To ScreenB") } } } @Composable fun ScreenB(cartId: String, onNavigateBack: () -> Unit) { val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { println("😇 ScreenB -> DisposableEffect") onDispose { println("😇 ScreenB -> DisposableEffect - onDispose") } } LaunchedEffect(cartId) { println("😇 ScreenB -> LaunchedEffect") } Column{ Column(modifier = Modifier) { Button(onClick = onNavigateBack) { Text("Back to Screen A") } } } } Output:- when ScreenA init 😇 ScreenA -> DisposableEffect 😇 ScreenA -> LaunchedEffect Navigate To ScreenA -> ScreenB 😇 ScreenB -> DisposableEffect 😇 ScreenB -> LaunchedEffect 😇 ScreenA -> DisposableEffect - onDispose Navigate back to ScreenB -> ScreenA 😇 ScreenA -> DisposableEffect 😇 ScreenA -> LaunchedEffect 😇 ScreenB -> DisposableEffect - onDispose
What Actually Happens Internally (Jetpack Compose Navigation)?
Compose Navigation’s behavior around composables in a NavHost follows this logic:
1. Composition of the new destination (ScreenA here) happens first.
- Compose immediately creates the UI for the new screen upon navigation.
- DisposableEffect and LaunchedEffect of the new screen (ScreenA) immediately execute during or right after this new composition.
2. Disposal of the previous screen’s composable (ScreenB) happens after the new destination is successfully composed and committed to the UI hierarchy.
- Compose keeps the previous composable (ScreenB) briefly active until the new one (ScreenA) is stable, ensuring smooth navigation.
- Only once the new composable (ScreenA) is fully composed does Compose clean up and remove (dispose) the previous composable (ScreenB).
Thus, the real-world lifecycle flow during navigation is:
Navigate back (ScreenB → ScreenA) │ ├── 1️⃣ Compose immediately creates ScreenA │ ├─ ScreenA DisposableEffect executes instantly. │ └─ ScreenA LaunchedEffect coroutine launched. │ └── 2️⃣ After successfully stabilizing ScreenA: └─ ScreenB DisposableEffect onDispose runs.
Job Offers
Why Does Compose Do It This Way?
Compose Navigation handles screens carefully to ensure seamless user experience and stability:
- It doesn’t prematurely dispose of the previous composable (ScreenB) before ensuring that the destination (ScreenA) is composed and ready.
- This avoids visual glitches or blank screens during transitions.
- Only after ensuring the new screen is safely in place does Compose trigger disposal of previous screen.
Internal Jetpack Compose NavHost Mechanics (Simplified):
Internally, Compose’s NavHost works like this when calling popBackStack() or navigate():
- New route composition begins (Composable creation).
- After successful composition and frame commit, old composable nodes that are no longer in the NavHost’s backstack are marked for disposal.
- Compose then runs disposal logic (onDispose) of these removed composables in the next frame.
Thus, even though you visually navigate back immediately, disposal operations (onDispose) are slightly deferred for UI stability.
If you have any questions, just drop a comment, and I’ll get back to you ASAP. 💬✨
We’ll be diving deeper into Jetpack Compose soon, so stay tuned! 🚀
Until then, happy coding! 🎉👨💻
This article was previously published on proandroiddev.com.