androidandroid-jetpack-composecompose-recomposition

Why is using a LaunchedEffect with a key triggering recomposition of the whole Composable hierarchy?


I stumbled across a strange issue with using a LaunchedEffect(key) in Jetpack Compose and tracked it down to the following minimal example:

Surface(
    modifier = Modifier.fillMaxSize().safeDrawingPadding(),
    color = MaterialTheme.colorScheme.background
) {

    var pseudoState by remember {
        mutableStateOf(false)
    }

    LaunchedEffect(Unit) {}  // NOTE: I am using Unit here

    Column {
        Button(onClick = { pseudoState = !pseudoState }) {
            Text(text = "TOGGLE pseudoState to ${!pseudoState}")
        }
        Text(text = "Random: ${Math.random()}")
    }
}

When I run it, note that the Button is correctly recomposed after clicking it, and the other Text Composable is skipped.

enter image description here


Now, I make one small adjustment by providing pseudoState as a key to the empty LaunchedEffect:

LaunchedEffect(pseudoState) {}  // NOTE: I am using pseudoState now

Now, with every single click on the Button, both the Button and the Text get recomposed:

enter image description here

Why is this happening?


I use the following dependencies:

[versions]

agp = "8.3.2"
kotlin = "1.9.0"
coreKtx = "1.15.0"
lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.9.3"
composeBom = "2024.11.00"

Solution

  • When a state is read in a scope, non-inline Composable function that returns Unit and not annotated with @NonRestartableComposable, that scope is subject to recomposition check, if there any state reads in that scope or child scopes below it.

    When recomposition check is done due the read in state or below if a composable is skippable no inputs has changed it gets skipped, otherwise it recomposes. Numbers shown in layout inspector show these. But when there not any numbers or no change in recomposition/skipped numbers it means there is no need further check for composition down the composition tree.

    You can check my question/answer where reading a state causes everything to be recomposed, i used random color but random acts the same way which creates different output when that scope is run.

    Easiest way to check if a scope is eligible for recomposition is layout inspector if it doesn't show and composition or skipped number there is state read or input change in that scope as in first example, if there are any then you see numbers change.

    Check this example

    @Preview
    @Composable
    fun MyComposable() {
        var counter by remember {
            mutableIntStateOf(0)
        }
    
        Column(
            modifier = Modifier.fillMaxSize()
        ) {
    
            Button(
                onClick = {
                    counter++
                }
            ) {
                Text(
                    text = "Counter: $counter",
                    modifier = Modifier.fillMaxWidth().border(2.dp, getRandomColor()).padding(16.dp)
                )
            }
    
            MyCustomScope {
                Text(
                    text = "Some text",
                    modifier = Modifier.fillMaxWidth().border(2.dp, getRandomColor()).padding(16.dp)
    
                )
            }
    
            MyCustomScope {
                Text(
                    text = "Counter $counter",
                    modifier = Modifier.fillMaxWidth().border(2.dp, getRandomColor()).padding(16.dp)
                )
    
                MyCustomScope {
                    Text(
                        text = "Inner scope text",
                        modifier = Modifier.fillMaxWidth().border(2.dp, getRandomColor()).padding(16.dp)
    
                    )
                }
            }
        }
    }
    

    When you increase counter

    enter image description here

    @Composable
    fun MyCustomScope(content: @Composable () -> Unit) {
        content()
    }
    

    As you can see in the picture, top MyCustomScope does not have a check for recomposition, no numbers displayed, because there is no state read neither in nor its parent scope.

    However if you check second one below where counter is read in MyComposable scope, even just adding a state is a read like LaunchEffect reading key, it creates recomposition check for that scope. Button scope and 2 outer MyCustomScope skips recomposition. If you add a Text with random color modifier inside Column you can observe that it will recompose when counter is read.

    @Preview
    @Composable
    fun MyComposable() {
        var counter by remember {
            mutableIntStateOf(0)
        }
    
        Column(
            modifier = Modifier.fillMaxSize()
        ) {
    
            counter
    
            Button(
                onClick = {
                    counter++
                }
            ) {
                Text(
                    text = "Counter: $counter",
                    modifier = Modifier.fillMaxWidth().border(2.dp, getRandomColor()).padding(16.dp)
                )
            }
    
            MyCustomScope {
                Text(
                    text = "Some text",
                    modifier = Modifier.fillMaxWidth().border(2.dp, getRandomColor()).padding(16.dp)
    
                )
            }
    
            MyCustomScope {
                Text(
                    text = "Inner scope first text",
                    modifier = Modifier.fillMaxWidth().border(2.dp, getRandomColor()).padding(16.dp)
                )
    
                MyCustomScope {
                    Text(
                        text = "Inner scope text",
                        modifier = Modifier.fillMaxWidth().border(2.dp, getRandomColor()).padding(16.dp)
    
                    )
                }
            }
        }
    }
    

    enter image description here

    And if you create another state does doesn't change but that is read in deepest scope like below things get even more interesting.

    @Preview
    @Composable
    fun MyComposable() {
        var counter by remember {
            mutableIntStateOf(0)
        }
    
        var counter2 by remember {
            mutableIntStateOf(0)
        }
    
        Column(
            modifier = Modifier.fillMaxSize()
        ) {
    
    
            counter
    
            Button(
                onClick = {
                    counter++
                }
            ) {
                Text(
                    text = "Counter: $counter",
                    modifier = Modifier.fillMaxWidth().border(2.dp, getRandomColor()).padding(16.dp)
                )
            }
    
            Button(
                onClick = {
                    counter2++
                }
            ) {
                Text(
                    text = "Counter: $counter2",
                    modifier = Modifier.fillMaxWidth().border(2.dp, getRandomColor()).padding(16.dp)
                )
            }
    
            MyCustomScope {
                Text(
                    text = "Some text",
                    modifier = Modifier.fillMaxWidth().border(2.dp, getRandomColor()).padding(16.dp)
    
                )
            }
    
            MyCustomScope {
                Text(
                    text = "Inner scope first text",
                    modifier = Modifier.fillMaxWidth().border(2.dp, getRandomColor()).padding(16.dp)
                )
    
                MyCustomScope {
                    Text(
                        text = "counter2: $counter2",
                        modifier = Modifier.fillMaxWidth()
                            .border(2.dp, getRandomColor()).padding(16.dp)
                    )
                }
            }
        }
    }
    

    enter image description here

    Since counter2 is read in inner scope, Compose checks if inputs of functions have chanhed, since there is a random color modifier that returns new Modifier, it recomposes. If you remove this modifier it skips recomposition.

    Another example is

    @Preview
    @Composable
    fun AnotherCompositionTest() {
    
        val viewModel = remember { TestViewModel() }
    
        var counter by remember {
            mutableIntStateOf(0)
        }
    
        Column(
            modifier = Modifier.fillMaxSize()
        ) {
            counter
    
            Text(
                text = "Some text",
            )
            Text(
                text = "Some text with Modifier",
                modifier = Modifier.fillMaxWidth().border(2.dp, getRandomColor()).padding(16.dp)
            )
            Text("ViewModel : ${viewModel.someText}")
    
            Button(
                onClick = {
                    counter++
                }
            ) {
                Text(
                    text = "Counter: $counter",
                    modifier = Modifier.fillMaxWidth().border(2.dp, getRandomColor()).padding(16.dp)
                )
            }
    
            Button(
                onClick = {
                    viewModel.someText = UUID.randomUUID().toString()
                }
            ) {
                Text(
                    text = "Change ViewModel Text",
                    modifier = Modifier.fillMaxWidth().border(2.dp, getRandomColor()).padding(16.dp)
                )
            }
        }
    }
    
    
    class TestViewModel : ViewModel() {
        var someText = "Hello"
    }
    

    enter image description here

    When you increase counter see that Text with Modifier recomposes while other 2 skips. Then click and change ViewModel text you won't see any recomposition. Then if you increase counter you will see that Text that writes Text("ViewModel : ${viewModel.someText}") recompose because its input changed in this recomposition.