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
@Composable
function runs first - Then the
DisposableEffect
, theSideEffect
and theLaunchedEffect
in that order
When a Composable
exits composition:
- The
onDispose
of the correspondingDisposableEffect
runs
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 -
DisposableEffect
s run first, followed bySideEffect
s, followed byLaunchedEffect
On Exiting Composition:
DisposableEffect
s 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
DisposableEffect
s are registered before the outgoingDisposableEffect
s are disposed - Incoming
DisposableEffect
s are run after the outgoingDisposableEffect
s 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 Composable
s that are never in composition together, to acquire
/release
to the same resource in their DisposableEffect
s.
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 DisposableEffect
s 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 Composable
s 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
DisposableEffect
andLaunchedEffect
areComposable
functions 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
Composable
function runs first - All the Side Effects run in the order they are declared / registered
- Among the different Side Effects -
DisposableEffect
s run first, followed bySideEffect
s, followed byLaunchedEffect
โฌ ๏ธ On Exiting Composition:
DisposableEffect
s 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
DisposableEffect
s are registered before the outgoingDisposableEffect
s are disposed - Incoming
DisposableEffect
s are run after the outgoingDisposableEffect
s are disposed
๐ซ When animated, the outgoing DisposableEffect
s 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