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, the SideEffect and the LaunchedEffect in that order

When a Composable exits composition:

  • The onDispose of the corresponding DisposableEffect 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 - DisposableEffects run first, followed by SideEffects, followed by LaunchedEffect

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 outgoing DisposableEffects are disposed
  • Incoming DisposableEffects are run after the outgoing DisposableEffects 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 DisposableEffect and LaunchedEffect are Composable 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 - DisposableEffects run first, followed by SideEffects, followed by LaunchedEffect

โฌ…๏ธ 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 outgoing DisposableEffects are disposed
  • Incoming DisposableEffects are run after the outgoing DisposableEffects 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