Don’t do this State Mistake in Jetpack/Jetbrain Compose — State Destructuring vs State Delegates.
There are two ways to create a Mutable State in Jetpack/Jetbrain Compose. The first and my favorite way is by using Destructing
and another way is to use Delegate
.
Modified Photo by Anders Norrback Bornholm on Unsplash
Mutable State with Destructing 🔨
If you don’t know the concept of Destructing in Kotlin Learn all about them from here :
If you look at the MutableState signature :
@Stable | |
interface MutableState<T> : State<T> { | |
override var value: T | |
operator fun component1(): T | |
operator fun component2(): (T) -> Unit | |
} |
It has component1
and component2
operators which means, it can be destructed like :
val(count, setCount) = mutableStateOf(0)
Where count
gets its value from component1
and setCount
is a lambda coming from component2
.
Mutable State with Delegates 🍇
Learn delegate from the official channel :
https://kotlinlang.org/docs/delegated-properties.html
If you look into State and Mutable State documentation :
inline operator fun <T> State<T>.getValue(thisObj: Any?, property: KProperty<*>): T = value | |
inline operator fun <T> MutableState<T>.setValue(thisObj: Any?, property: KProperty<*>, value: T) { | |
this.value = value | |
} |
getValue(...)
is defined over State
and setValue(...)
is defined over MutableState
, thus we can use the concept of delegate over it example :
// dont forget these imports | |
import androidx.compose.runtime.getValue | |
import androidx.compose.runtime.setValue | |
// for read only from state | |
val count by mutableStateOf(0) | |
// for read and write on state | |
var count by mutableStateOf(0) |
We control reading and writing on a state by defining val
or var
reference type of variable.
In my personal opinion using Destructing way feels more readable and maintainable to me, as I’m aware of where the set operation has happened by tracking setCount()
, and where the get operation is happening by tracking count
. Whereas tracking state created from delegate you will have a hard time, also In my experience, I feel working with var
ends up being exploited by developers easily.
Also in scenarios where I’m forwarding state to children composable, I don’t have to create in-place lambdas I can directly pass the reference, for instance :
val(isChecked, setChecked) = remember { | |
mutableStateOf(value = false) | |
} | |
Checkbox( | |
checked = isChecked, | |
onCheckedChange = setChecked | |
) | |
// vs | |
var checked by remember { | |
mutableStateOf(value = false) | |
} | |
Checkbox( | |
checked = checked, | |
onCheckedChange = /* in place lambda */{ | |
checked = it | |
} | |
) |
Well until now I have been a fan of destructing mutable states, but recently I have encountered a scenario which raised concern in my mind regarding this approach.
Consider the following code, I’m incrementing state by 1 when a button is clicked and displaying it on UI :
// Create a counter state | |
val(count, setCount) = remember { | |
mutableStateOf(0) | |
} | |
Column( | |
modifier = Modifier | |
.fillMaxSize() | |
.background(Color.White), | |
verticalArrangement = Arrangement.Center, | |
horizontalAlignment = Alignment.CenterHorizontally | |
) { | |
// display state on UI | |
Text( | |
text = "$count", //👈 display current value of state | |
fontSize = 32.sp, | |
) | |
// click button to increment count by 1 | |
Button( | |
onClick = { | |
setCount(count + 1) //👈 update state | |
} | |
) { | |
Text(text = "Increment by 1") | |
} // button end | |
} // column end |
Output :
Button click increment state by 1 using state destructuring
I want to update the Snippet to increment by 2 instead of 1, see the following changes I have done :
// Create a counter state | |
val(count, setCount) = remember { | |
mutableStateOf(0) | |
} | |
Column( | |
modifier = Modifier | |
.fillMaxSize() | |
.background(Color.White), | |
verticalArrangement = Arrangement.Center, | |
horizontalAlignment = Alignment.CenterHorizontally | |
) { | |
// display state on UI | |
Text( | |
text = "$count", //👈 display current value of state | |
fontSize = 32. sp, | |
) | |
// click button to increment count by 2 | |
Button( | |
onClick = { | |
setCount(count + 1) //👈 update state 1st time | |
setCount(count + 1) //👈 update state 2nd time | |
} | |
) { | |
Text(text = "Increment by 2") | |
} // button end | |
} // column end |
Job Offers
Output :
Button click increment state by 2 using state destructuring
🤯 WTF — it didn’t work!
Let’s Investigate 🤔 why?
when you click on the button following is what is happening in the background :
Button( | |
onClick = { | |
// thing that is happening is : | |
setCount(count + 1) //👉 0 + 1 | |
setCount(count + 1) //👉 0 + 1 | |
} | |
) { | |
Text(text = "Increment by 2") | |
} // button end |
but why??? setCount()
should be up the mutable state right??
What broke?? is it Compose or MutableState?? :
Well, none is broken. I dig a bit deep into what happened and I figured out that :
- Compose internally observe all reads and writes to state, we don’t need to understand inner working for this discussion, but you just need to know that compose works on a snapshot system, all the writes you do on mutable State effects this snapshot.
- If there is a conflict between the previous and new snapshot then some action will happen to resolve the conflict. In terms of Compose Framework, it would lead to a trigger to re-composition.
- So when the mutable state value is updated it would check the snapshot policy and validate if snapshot is having any conflict,
- if the answer to that is yes, then the mutable state would inform compose to recompose and store the latest value, but instead of making the latest value an immediate state value, it would set it as a candidate value for the next recomposition cycle to read from.
- Thus even if you have done multiple state updates, the last value you set would be treated as the latest candidate for the state value.
- When recomposition would happen it reads the latest value from the mutable state and apply changes.
🤷♂️Okay… does that mean we have to wait until candidate value becomes real value i.e. till recomposition to update the state? i.e. doing multiple set State operations in a row is wrong in compose???
Well In most cases we can avoid this situation if we create a launchEffect
and put our state change logic into that or refactor our logic to create only one set state.
but ideally, this isn’t a behavior we expect from updating a state.
Let’s see the same code using the delegate pattern :
// Create a counter state | |
var count by remember { | |
mutableStateOf(value = 0) | |
} | |
Column( | |
modifier = Modifier | |
.fillMaxSize() | |
.background(Color.White), | |
verticalArrangement = Arrangement.Center, | |
horizontalAlignment = Alignment.CenterHorizontally | |
) { | |
// display state on UI | |
Text( | |
text = "$count", //👈 display current value of state | |
fontSize = 32. sp, | |
) | |
// click button to increment count by 2 | |
Button(onClick = { | |
count = count + 1 // update state | |
count = count + 1 // update state | |
}) { | |
Text(text = "Increment by 2") | |
}// button end | |
} // column end |
Output :
Button click increment state by 2 using state delegation
this snippet would perform the operation correctly! 🤯. Why did this happen? doesn’t the same rule be applied to the behavior?
Well no! Creating State with Destructing ≠ Creating Sate with Delegate
The internally same thing has happened, but when you use state delegation method, whenever you are reading a value from the state it is delegating the operation of getting to MutableState.value
, which returns candidate value instead of real value (if snapshot ids have changed).
But when you are working with Destructing state, component1
has only called MutableState.value
only once while destructing and has set it to count
variable i.e getter part. if you again read it from the getter it won’t delegate the task to MutableState.value
check and get the candidate value it would just return back last computed value when it was de-structuring.
that means while using Destructuring you have written code similar to :
@Composable | |
fun Foo() { | |
val countState = remember { mutableStateOf(0) } | |
Button(onClick = { | |
val count = countState.value //👈 count is 0 | |
val setCount = { lastestCount: Int -> | |
countState.value = lastestCount | |
} | |
setCount(count+1) //👈 0 + 1 | |
setCount(count+1) //👈 0 + 1 | |
}) { | |
Text(text = "Increment by 2") | |
}// button end | |
} |
Thus you have to wait for recomposition to re-initialize the value of the mutable state.
So actually no behavior of Compose or MutableState is broken, it is just the way destructuring and delegation work in the language.
Conclusion 💆🏻♀️
Though I still feel Destructing
is better than Delegating
state but to work with the consistent state value it would be best to go with the Delegation path.
Thanks for continuing some far. If you liked my article check out more of my article and content from https://chetangupta.net/
If you want me to write an article or train Android teams for your business do reach out by clicking 👉 here.
Until Next Time! Happy Hacking 👨🏻💻
This article was originally published on proandroiddev.com on October 18, 2022