Yet another pitfall in Jetpack Compose you must be aware of
Underpinning the importance of understanding the theoretical aspect of Jetpack Compose
Introduction
Hello everyone! Hope you’re doing well. In this article, I will explain a pitfall that I stumbled upon when I was using Jetpack Compose. I’ve already written about another pitfall that I recently found. If you’re interested, you can read about it here. The pitfall we discuss here is related to using property delegates for state in compose, recomposition scopes and the derivedStateOf
function.
Pre-requisites
- Knowledge of using property delegates for compose state objects.
- Understanding of the
derivedStateOf
function. I’ve written a thorough article explaining when and where to usederivedStateOf
. If you’re interested, you can read it here. - Basic knowledge of recomposition scopes would be a plus, but it isn’t specifically required.
The setup
Let’s say we’re building a counter app that has two text composables and a button. One of the text composables displays the current value of the counter, and the other displays whether the number is divisible by 10. The button will be responsible for incrementing the counter. Let’s use a ViewModel to store the current value of the counter and also a function that is responsible for incrementing the value of the counter. The code for that might look like this.
class TestViewModel : ViewModel() {
var currentCounterValue by mutableStateOf(0)
private set
fun incrementCounter() {
currentCounterValue += 1
}
}
@Composable
private fun SampleApp() {
val viewModel = viewModel<TestViewModel>()
val counterValue = viewModel.currentCounterValue
val isDivisible by remember { derivedStateOf { counterValue % 10 == 0 } }
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "$counterValue")
Button(
onClick = { viewModel.incrementCounter() },
content = { Text(text = "Increment") }
)
Text(text = "Is divisible by 10 : $isDivisible")
}
}
If you have an understanding ofderivedStateOf
you would probably know why we are using it here. We don’t need to recompose the text composable that displays whether the current value is divisible by 10, every single time the counter value changes. We only want the text composable to recompose if and only if, the value changes from true to false, and vice-versa.
However, in this case, derivedStateOf
has no effect! Shocker!, isn’t it? It’s because of a concept called “Stability” in compose. It’s a topic that’ll definitely require a whole other article. But, if you want to delve deep into it, you can read Ben Trengrove’s article published in the Android Developers publication. TLDR; Compose is smart about recompositions, and tries to recompose as optimally as possible, based on the types of the parameters passed to a composable.
Let’s take an example. If we increment the counter from 1 to 2, the contents of the string remain the same, ie. “Is divisible by 10 : false”. Compose sees this and skips the recomposition of the text composable, regardless of whether we use derivedStateOf
or not. This type of optimization was only possible because the state is of type String
, which belongs to a special group of types that compose can optimize on its own.
Since the main aim of this article is not about explaining the concept of “Stability in compose”, and more about a pitfall associated with using derivedStateOf
, and state property delegates, we’ll be going ahead with this example. Here’s how the app currently looks.
The pitfall
You’ll notice that the text composable that shows whether the current counter value is divisible by 10, never updates! Could you guess why it doesn't change? It all has to do with recomposition scopes.
A Recomposition scope refers to the scope of a recomposition. In layman’s terms, it refers to the part of the UI tree that will get re-composed. When a state changes, the recomposition process will start from the nearest composable above the place where the state is read. Here’s an example.
@Composable
fun SomeComposable() {
val state1 by remember { mutableStateOf(0) }
val state2 by remember { mutableStateOf(0) }
Column {
Row { /* Recomposition scope of state1 */
// state1 is being read here. The nearest composable
// is the first row composable. If state1 changes, then, only the
// content of the first row (ie. this trailing lambda)
// gets recomposed.
Text(text = "state1 = $state1")
}
Row { /* Recomposition scope of state2 */
// state2 is being read here. The nearest composable
// is the second row composable. If state2 changes, then, only the
// content of the second row (ie.this trailing lambda)
// gets recomposed.
Text(text = "state2 = $state2")
}
}
.
.
.
}
Let’s get back to our initial example and see where the issue lies.
@Composable
private fun SampleApp() { // recomposition scope
val viewModel = viewModel<TestViewModel>()
// if this state changes, this entire composable (ie. the "SampleApp"
// composable), will get re-composed.
val counterValue = viewModel.currentCounterValue // state read
val isDivisible by remember { derivedStateOf { counterValue % 10 == 0 } }
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "$counterValue")
Button(
onClick = { viewModel.incrementCounter() },
content = { Text(text = "Increment") }
)
Text(text = "Is divisible by 10 : $isDivisible")
}
}
Since we are using a property delegate in our ViewModel, the state can be directly read in the composable as viewModel.currentCounterValue
. And, this is where the problem lies.
If you know about derivedStateOf
, any change to any state, read within it, will cause the computation to be re-executed. Here, the state is not read inside the derivedStateOf
function. Rather, the state is read above it, and the initial value is used inside the derivedStateOf
method. This is equivalent to using a normal, non-compose state variable within the derivedStateOf
method! This explains why the text composable doesn’t update.
// the equivalent of what is happening in the above example
var state by remember { mutableStateOf(0) }
val counterValue = state.value
val isDivisible by remember { derivedStateOf { counterValue % 10 == 0 } }
Since we are using property delegates within the ViewModel, when we access that value of the state, we are literally reading the value of the state. Essentially, this line val counterValue = viewModel.currentCounterValue
is equivalent to val counterValue = currentCounterValue.value
, if currentCounterValue
were to be declared as a state variable, directly within the composable. If the ViewModel didn’t use property delegation, then it would be equivalent to val counterValue = viewModel.currentCounterValue.value.
In our example, we are using property delegation for the state variable. Hence, we are able to directly access the value of the state variable.
The fix
The fix is pretty straightforward once you realize what is happening. You could read the state within the derivedStateOf
method, like so.
v̶a̶l̶ ̶c̶o̶u̶n̶t̶e̶r̶V̶a̶l̶u̶e̶ ̶=̶ ̶v̶i̶e̶w̶M̶o̶d̶e̶l̶.̶c̶u̶r̶r̶e̶n̶t̶C̶o̶u̶n̶t̶e̶r̶V̶a̶l̶u̶e̶
val isDivisible by remember { derivedStateOf { viewModel.currentCounterValue % 10 == 0 } }
Since the state currentCounterValue
is read within derivedStateOf
, any change to it, will cause the state to be recomputed. But the issue is, if there is any other place where we want to get the currentCounterValue
, we must now refer to it in the following manner - viewModel.currentCounterValue
. A cleaner way to do it would be to pass the state as a key to the remember block.
val counterValue = viewModel.currentCounterValue
val isDivisible by remember(counterValue) { derivedStateOf { counterValue % 10 == 0 } }
Now, if you try running the app, the text will correctly update. Let’s update the code and understand why this works.
@Composable
private fun SampleApp() { // recomposition scope
val viewModel = viewModel<TestViewModel>()
val counterValue = viewModel.currentCounterValue // state read
// using remember with keys
val isDivisible by remember(counterValue) { derivedStateOf { counterValue % 10 == 0 } }
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "$counterValue")
Button(
onClick = { viewModel.incrementCounter() },
content = { Text(text = "Increment") }
)
Text(text = "Is divisible by 10 : $isDivisible")
}
}
This works because, the nearest composable relative to where the state currentCounterValue
is being read, is the SampleApp
composable. So, the SampleApp
composable is the start of the recomposition scope. Once the counterValue
changes, the SampleApp
composable gets recomposed. During recomposition, the remember block will notice that the key passed to it has changed. This in turn will cause the value of the state to be re-calculated by derivedStateOf{}
.
Wrap up
And, that wraps it up 🎉! I understand that these kinds of pitfalls might scare a person who’s learning compose for the first time. Without the theoretical knowledge of how recomposition scopes work and how derivedStateOf
behaves when state is read within it, I bet you wouldn’t have been able to solve it. But, pitfalls like these don’t mean that compose is bad. Compose is not bad by any means at all. But, as for anything related to programming concepts, learning the theoretical aspect is as important as learning the practical aspect.
Anyways, hopefully, you found this blog post helpful! If you liked this article, feel free to check out my other articles as well. As always, I would like to thank you for taking the time to read this article😊. I wish you the best of luck! Happy coding 👨💻! Go create some awesome Android apps 👨💻! Cheers!
If you really liked my article and want to support me, you can do so, by clicking this link. Thank you so much for being generous ❤️, it really motivates me to keep going, and it helps me to keep my articles free for anyone to read. If you don’t feel like supporting, that’s fine too! The fact that you took some time off your schedule to read my article means a lot to me. Thank you 🙂