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.
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:
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"
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
@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)
)
}
}
}
}
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)
)
}
}
}
}
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"
}
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.