Understanding Composition and Side Effects
Jetpack Compose is a Declarative UI Framework that allows us to define UI, state and side effects by declaring functions. A lot of them. Named and anonymous (lambdas). We will try and understand when does the framework invoke the functions we are declaring.
Some of those functions we declare are Side Effects. Specifically we are going to look at LaunchedEffect, SideEffect and DisposableEffect
They look like this in the code
LaunchedEffect(/*key(s)*/) {
    /* effect that we are declaring as a lambda */
}When the above code is run, LaunchedEffect the function runs and registers the lambda we passed, to run it when it needs to be run. Well when exactly? We should get an idea very soon.
Let's dive in.
๐ Setup
A @Composable which sets up a bunch of logs inside Side Effects - DisposableEffect, SideEffect and LaunchedEffect
@Composable
fun Effects(
    logTag: String,
    key: Any = logTag,
) {
    val tag = logTag.padEnd(25)
    log("$tag - Registering Effects")
    LaunchedEffect(key) {
        log("$tag - LaunchedEffect")
    }
    DisposableEffect(key) {
        log("$tag - DisposableEffect")
        onDispose {
            log("$tag - onDispose")
        }
    }
    SideEffect {
        log("$tag - SideEffect")
    }
}And a TrafficLight that shows an emoji in Text. Also uses the Effects we defined above for logging
@Composable
fun TrafficLight(
    lightEmoji: String,
    modifier: Modifier = Modifier
) {
    Text(lightEmoji, fontSize = 120.sp, modifier = modifier)
    Effects("TrafficLight($lightEmoji)")
}Touch And Go
We are going to start by adding and removing this TrafficLight on touch as shown below
@Composable
fun TouchAndGo() {
    var isVisible by remember { mutableStateOf(false) }
    Box(
        contentAlignment = Alignment.Center,
        modifier = Modifier
            .fillMaxSize()
            .clickable {
                log("---Click---------------------")
                isVisible = !isVisible
            },
    ) {
        if (isVisible) {
            TrafficLight(lightEmoji = "๐ข")
        }
    }
}Initially we show nothing. On click, we show the green light ๐ข.
Together, this is what we have got
@Composable
fun TouchAndGo() {
    var isVisible by remember { mutableStateOf(false) }
    Box(
        contentAlignment = Alignment.Center,
        modifier = Modifier
            .fillMaxSize()
            .clickable {
                log("---Click---------------------")
                isVisible = !isVisible
            },
    ) {
        if (isVisible) {
            TrafficLight(lightEmoji = "๐ข")
        }
    }
}
@Composable
fun TrafficLight(
    lightEmoji: String,
    modifier: Modifier = Modifier
) {
    Text(lightEmoji, fontSize = 120.sp, modifier = modifier)
    Effects("TrafficLight($lightEmoji)")
}
@Composable
fun Effects(
    logTag: String,
    key: Any = logTag,
) {
    val tag = logTag.padEnd(25)
    log("$tag - Registering Effects")
    LaunchedEffect(key) {
        log("$tag - LaunchedEffect")
    }
    DisposableEffect(key) {
        log("$tag - DisposableEffect")
        onDispose {
            log("$tag - onDispose")
        }
    }
    SideEffect {
        log("$tag - SideEffect")
    }
}Let's have a look at the logs
---Click---------------------
TrafficLight(๐ข)     - Registering Effects
TrafficLight(๐ข)     - DisposableEffect
TrafficLight(๐ข)     - SideEffect
TrafficLight(๐ข)     - LaunchedEffect
---Click---------------------
TrafficLight(๐ข)     - onDispose
โจ Insights
When a Composable enters composition:
- Our @Composablefunction runs first
- Then the DisposableEffect, theSideEffectand theLaunchedEffectin that order
When a Composable exits composition:
- The onDisposeof the correspondingDisposableEffectruns
Let's make it slightly more interesting. Let's add Effects above and below the TrafficLight
 @Composable
 fun TouchAndGo() {
     var isVisible by remember { mutableStateOf(false) }
     Box(
         /*...*/
     ) {
         if (isVisible) {
+            Effects("Pre - TrafficLight(๐ข)")
             TrafficLight(lightEmoji = "๐ข")
+            Effects("Post - TrafficLight(๐ข)")
         }
     }
 }So what do we have on the logs now?
---Click---------------------
Pre - TrafficLight(๐ข)    - Registering Effects
TrafficLight(๐ข)          - Registering Effects
Post - TrafficLight(๐ข)   - Registering Effects
Pre - TrafficLight(๐ข)    - DisposableEffect
TrafficLight(๐ข)          - DisposableEffect
Post - TrafficLight(๐ข)   - DisposableEffect
Pre - TrafficLight(๐ข)    - SideEffect
TrafficLight(๐ข)          - SideEffect
Post - TrafficLight(๐ข)   - SideEffect
Pre - TrafficLight(๐ข)    - LaunchedEffect
TrafficLight(๐ข)          - LaunchedEffect
Post - TrafficLight(๐ข)   - LaunchedEffect
---Click---------------------
Post - TrafficLight(๐ข)   - onDispose
TrafficLight(๐ข)          - onDispose
Pre - TrafficLight(๐ข)    - onDispose
โจ Insights
On Entering Composition:
- All the Side Effects run in the order they are declared / registered
- Among the different Side Effects - DisposableEffects run first, followed bySideEffects, followed byLaunchedEffect
On Exiting Composition:
- DisposableEffects are disposed in the reverse order they are declared / registered. Last In First Out - LIFO. Like a stack!
Stop And Go
Toggle between ๐ข and ๐ด on click
@Composable
fun StopAndGo() {
    var go by remember { mutableStateOf(true) }
    Box(
        contentAlignment = Alignment.Center,
        modifier = Modifier
            .fillMaxSize()
            .clickable {
                log("---Click---------------------")
                go = !go
            },
    ) {
        val light = if (go) "๐ข" else "๐ด"
        TrafficLight(lightEmoji = light)
    }
}TrafficLight(๐ข)     - Registering Effects
TrafficLight(๐ข)     - DisposableEffect
TrafficLight(๐ข)     - SideEffect
TrafficLight(๐ข)     - LaunchedEffect
---Click---------------------
TrafficLight(๐ด)     - Registering Effects
TrafficLight(๐ข)     - onDispose
TrafficLight(๐ด)     - DisposableEffect
TrafficLight(๐ด)     - SideEffect
TrafficLight(๐ด)     - LaunchedEffect
โจ Insights
- Incoming DisposableEffects are registered before the outgoingDisposableEffects are disposed
- Incoming DisposableEffects are run after the outgoingDisposableEffects are disposed
One might have expected this, because compose runtime figures out what are incoming and what are outgoing only after it runs / re-runs our Composable functions based on the new State.
Nevertheless, this is an important aspect to keep in mind. Since this makes it safe for two Composables that are never in composition together, to acquire/release to the same resource in their DisposableEffects.
Stop Fade Go
More often than not, we animate our changes. Let's look at the order of execution when we add animation to the above example
 @Composable
 fun StopFadeGo() {
     var go by remember { mutableStateOf(true) }
 
     Box(
         contentAlignment = Alignment.Center,
         modifier = Modifier
             .fillMaxSize()
             .clickable {
                 log("---Click---------------------")
                 go = !go
             },
     ) {
         val light = if (go) "๐ข" else "๐ด"
+        Crossfade(targetState = light) {
+            TrafficLight(lightEmoji = it)
+        }
     }
 }TrafficLight(๐ข)     - Registering Effects
TrafficLight(๐ข)     - DisposableEffect
TrafficLight(๐ข)     - SideEffect
TrafficLight(๐ข)     - LaunchedEffect
---Click---------------------
TrafficLight(๐ด)     - Registering Effects
TrafficLight(๐ด)     - DisposableEffect
TrafficLight(๐ด)     - SideEffect
TrafficLight(๐ด)     - LaunchedEffect
TrafficLight(๐ข)     - onDispose
โจ Insights
When animated, the outgoing DisposableEffects are disposed only after the animation is complete
Might feel obvious in hindsight. But it is important to keep in mind that since the incoming DisposableEffect runs before the outgoing disposes. This basically doesn't allow those Composables to acquire/attach-to the same resource.
Ready Set Go
๐ด Ready -> ๐ด Set -> ๐ข Go on click
@Composable
fun ReadySetGo() {
    var count by remember { mutableStateOf(1) }
    Box(
        contentAlignment = Alignment.Center,
        modifier = Modifier
            .fillMaxSize()
            .clickable {
                log("---Click---------------------")
                count++
            },
    ) {
        val step = count % 3
        val light = if (step == 0) "๐ข" else "๐ด"
        val message = when (step) {
            1 -> "Ready"
            2 -> "Set"
            0 -> "GO!"
            else -> "Uh Oh!"
        }
        Column(horizontalAlignment = Alignment.CenterHorizontally) {
            TrafficLight(lightEmoji = light)
            Spacer(modifier = Modifier.height(8.dp))
            Text(message, fontSize = 36.sp)
        }
    }
}TrafficLight(๐ด)     - Registering Effects
TrafficLight(๐ด)     - DisposableEffect
TrafficLight(๐ด)     - SideEffect
TrafficLight(๐ด)     - LaunchedEffect
---Click---------------------
---Click---------------------
TrafficLight(๐ข)     - Registering Effects
TrafficLight(๐ด)     - onDispose
TrafficLight(๐ข)     - DisposableEffect
TrafficLight(๐ข)     - SideEffect
TrafficLight(๐ข)     - LaunchedEffect
Note that for both "Ready" and "Set" states, the light is ๐ด
โจ Insights
Composition and Effects are skipped when the inputs don't change!
Just like the documentation says. But what does "inputs not changing" really mean? Let's find out.
Instead of passing in a String, let's create our own class
private class Light(val emoji: String)Update the TrafficLight and ReadySetGo to use Light instead of a String
 @Composable
 fun ReadySetGoClass() {
     var count by remember { mutableStateOf(1) }
 
     Box(
         contentAlignment = Alignment.Center,
         modifier = Modifier
             .fillMaxSize()
             .clickable {
                 log("---Click---------------------")
                 count++
             },
     ) {
         val step = count % 3
+        val light = if (step == 0) Light("๐ข") else Light("๐ด")
         val message = when (step) {
             1 -> "Ready"
             2 -> "Set"
             0 -> "GO!"
             else -> "Uh Oh!"
         }
         Column(horizontalAlignment = Alignment.CenterHorizontally) {
+            TrafficLight(light)
             Spacer(modifier = Modifier.height(8.dp))
             Text(message, fontSize = 36.sp)
         }
     }
 }
 
 @Composable
 private fun TrafficLight(
+    light: Light,
     modifier: Modifier = Modifier
 ) {
+    val lightEmoji = light.emoji
     Text(lightEmoji, fontSize = 120.sp, modifier = modifier)
 
+    Effects("TrafficLight($lightEmoji)", key = light)
 }Here are the logs after the change
TrafficLight(๐ด)     - Registering Effects
TrafficLight(๐ด)     - DisposableEffect
TrafficLight(๐ด)     - SideEffect
TrafficLight(๐ด)     - LaunchedEffect
---Click---------------------
TrafficLight(๐ด)     - Registering Effects
TrafficLight(๐ด)     - onDispose
TrafficLight(๐ด)     - DisposableEffect
TrafficLight(๐ด)     - SideEffect
TrafficLight(๐ด)     - LaunchedEffect
---Click---------------------
TrafficLight(๐ข)     - Registering Effects
TrafficLight(๐ด)     - onDispose
TrafficLight(๐ข)     - DisposableEffect
TrafficLight(๐ข)     - SideEffect
TrafficLight(๐ข)     - LaunchedEffect
Well what happened there!
Our Light doesn't implement .equals(). The default implementation returns true only if they are the same instances. But we are creating a new instance every time. So compose runtime sees these as different inputs.
Let's add a log to equals()
 private class Light(val emoji: String) {
     override fun equals(other: Any?): Boolean {
         if (other is Light) {
+            val result = super.equals(other)
+            log("$emoji.equals(${other.emoji}) = $result")
             return result
         }
         return super.equals(other)
     }
 }Haven't changed the implementation yet. Just added a log.
The same logs as above but with equals()
TrafficLight(๐ด)     - Registering Effects
TrafficLight(๐ด)     - DisposableEffect
TrafficLight(๐ด)     - SideEffect
TrafficLight(๐ด)     - LaunchedEffect
---Click---------------------
๐ด.equals(๐ด) = false
TrafficLight(๐ด)     - Registering Effects
๐ด.equals(๐ด) = false
๐ด.equals(๐ด) = false
TrafficLight(๐ด)     - onDispose
TrafficLight(๐ด)     - DisposableEffect
TrafficLight(๐ด)     - SideEffect
TrafficLight(๐ด)     - LaunchedEffect
---Click---------------------
๐ด.equals(๐ข) = false
TrafficLight(๐ข)     - Registering Effects
๐ด.equals(๐ข) = false
๐ด.equals(๐ข) = false
TrafficLight(๐ด)     - onDispose
TrafficLight(๐ข)     - DisposableEffect
TrafficLight(๐ข)     - SideEffect
TrafficLight(๐ข)     - LaunchedEffect
So compose runtime compared the inputs. It observed that they are different (.equals() returned false), so ran the composable with the new input. It then compared the inputs again, to see if it has to run the DisposableEffect and the LaunchedEffect and ran them again because it received false.
After all
DisposableEffectandLaunchedEffectareComposablefunctions themselves
Let's implement equals()
 private class Light(val emoji: String) {
     override fun equals(other: Any?): Boolean {
         if (other is Light) {
+            val result = emoji == other.emoji
             log("$emoji.equals(${other.emoji}) = $result")
             return result
         }
         return super.equals(other)
     }
 }TrafficLight(๐ด)     - Registering Effects
TrafficLight(๐ด)     - DisposableEffect
TrafficLight(๐ด)     - SideEffect
TrafficLight(๐ด)     - LaunchedEffect
---Click---------------------
๐ด.equals(๐ด) = true
---Click---------------------
๐ด.equals(๐ข) = false
TrafficLight(๐ข)     - Registering Effects
๐ด.equals(๐ข) = false
๐ด.equals(๐ข) = false
TrafficLight(๐ด)     - onDispose
TrafficLight(๐ข)     - DisposableEffect
TrafficLight(๐ข)     - SideEffect
TrafficLight(๐ข)     - LaunchedEffect
Back to normal.
Let's summarize all the insights
โจ Insights
โก๏ธ On Entering Composition:
- Our Composablefunction runs first
- All the Side Effects run in the order they are declared / registered
- Among the different Side Effects - DisposableEffects run first, followed bySideEffects, followed byLaunchedEffect
โฌ ๏ธ On Exiting Composition:
- DisposableEffects are disposed in the reverse order they are declared / registered. Last In First Out - LIFO. Like a stack!
๐ When a composable is being replaced with another or recomposed with the new state:
- Incoming DisposableEffects are registered before the outgoingDisposableEffects are disposed
- Incoming DisposableEffects are run after the outgoingDisposableEffects are disposed
๐ซ When animated, the outgoing DisposableEffects are disposed only after the animation is complete.
๐ซ Composition and Effects are skipped when the inputs don't change. Inputs are compared using their equals() method