Jetpack Compose Concepts
State
State is probably the trickiest thing to understand in Compose, but it doesn't have to be... Once you understand a little of what's going on behind the scenes, you can feel more confident in your UI design using Compose.
What is "state" in this context? It's the collection of data used by your application for rendering a UI.
Compose uses a Snapshot
system to track used state and changes to that state. The basic idea:
- Any state passed to or read within a composable function is tracked in a
Snapshot
- If Compose knows how to observe the read state, it adds an observer to watch for changes.
- If the state changes, Compose triggers a recomposition.
- The affected composable functions are called again, passing the new state values.
There are two "tricky parts" here"
- What kind of state can Compose observe?
- What happens if the observed state contains mutable data?
Literal data
Let's start with a simple example with fixed data:
@Composable
fun Display(
text: String,
) {
Text(
text = text,
// ... other parameters ...
)
}
...
val name = "Scott"
Display(name)
The display should never recompose, as the data cannot change.
Basic var properties
Suppose instead we have
var name = "Scott"
@Composable
fun TheCaller() {
ClickAndChange(name) { name = "Mike" }
}
@Composable
fun ClickAndChange(
text: String,
onClick: () -> Unit,
) {
Text(
text = text,
modifier = Modifier.clickable { onClick() }
)
}
This example wants to change the displayed name when it's clicked. But it doesn't work. Why?
Compose needs to be able to observe state in order to know when to recompose. The var
that we've defined doesn't have any sort of observable quality, so Compose has no idea that it has changed.
MutableState - a Compose-observable "bucket"
Let's tweak the above example:
var name = mutableStateOf("Scott") // CHANGED!
@Composable
fun TheCaller() {
ClickAndChange(name.value) { name.value = "Mike" }
}
@Composable
fun ClickAndChange(
text: String,
onClick: () -> Unit,
) {
Text(
text = text,
modifier = Modifier.clickable { onClick() }
)
}
This works! mutableStateOf
creates a MutableState
object that can hold our name, and be observed by Compose. I like to call this an "observable bucket" - you can place things in the bucket and when the value is different, observers can be notified. Compose uses State
objects like this to hold onto and observe data.
To understand why this works, we need to dig a bit into the way this is implemented. The following is a simplified implementation of MutableState
:
class MutableState<T>(
initialValue: T
) {
var value: T = initialValue
get() {
// track this mutable state as being read
// from the current Composable context
return field
}
set(value) {
field = value
// trigger recomposition on all Composable
// contexts that have read this mutable state
}
}
You can override the default get
/set
behavior of Kotlin properties, and here the Compose team uses it to create the "magic" of recomposition triggering.
The get()
is called whenever the value
is read. In our example, this happens to get the parameter value:
@Composable
fun TheCaller() {
ClickAndChange(name.value) { name.value = "Mike" }
}
The read is actually done in the context of TheCaller
- it's easiest to see this if we separate the name.value
call:
@Composable
fun TheCaller() {
val nameToPass = name.value
ClickAndChange(nameToPass) { name.value = "Mike" }
}
The Compose compiler plugin passes a Composer
as an extra parameter to the function, and sets up the Compose context. The resulting code looks a little like:
@Composable
fun TheCaller(composer: Composer) {
composer.start(123) // a unique "group number" for the context
val nameToPass = name.value
ClickAndChange(nameToPass) { name.value = "Mike" }
composer.end()
}
That start(123)
call sets up the context for use inside the UI tree and Snapshot
- when the get()
is called, the context is known so we can record which state is read in which context.
When the set()
is called (in this case, the name.value = "Mike"
in ClickAndChange(name.value) { name.value = "Mike" }
), the set()
informs the snapshot manager that the MutableState
has changed, which can then trigger recomposition for all contexts that had previously read the MutableState
.
Kinda like magic, but even cooler when you see what's going on behind the scenes...
Let's make it even magic-er...
Simplifying access with Kotlin property delegates
One of my favorite Kotlin features is Delegated Properties.
Sometimes you find yourself overriding get()
and set()
the same way, over and over. For example, suppose you wanted to log access and changes to certain properties. You might find yourself writing
var name: String = ""
get() {
// log the access
return field
}
set(value) {
field = value
// log the change
}
in multiple places. It gets worse as the functionality you want inside get()
and set()
grows.
Property delegation means calling the get()
and set()
functions of an object from your property's get()
and set()
. We will no longer use a backing field on the property; we'll manage the value (if necessary) inside the delegate object.
For example, if we wanted to delegate the above, we might write:
class Loggable<T>(initialValue: T) {
private var value: T = initialValue
operator fun getValue(thisRef: Any?, property: KProperty<*>): T {
// log the access
return value
}
operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
this.value = value
// log the change
}
}
An instance of this class manages a value of any type and logs when that value is accessed or changed. We use this via the by
keyword in Kotlin:
var name by Loggable("")
Behind this scenes the by
does something like:
private val nameLogger = Loggable("")
var name: String
get() = nameLogger.value
set(value) {
nameLogger.value = value
}
Because neither the get()
nor set()
for the name
property reference field
, there is no backing field for name
. Instead, we're storing the value inside the Loggable
instance.
As it turns out, we can use MutableState
as a property delegate with by
:
var name by mutableStateOf("Scott")
@Composable
fun TheCaller() {
ClickAndChange(name) { name = "Mike" }
}
This simplifies the use of name
. We no longer need to use name.value
; we can now just use name
with the same effect!
Don't put mutable types in that bucket!
One of the most common state errors is placing mutable types inside a MutableState
. Let's start with a simple Person
type:
data class Person(
val name: String,
)
and then use it in a mutable list in our UI:
private val _people = mutableListOf(Person("Scott"))
var people by mutableStateOf(_people)
@Composable
fun PeopleListScreen(
people: List<Person>,
onAddPerson: (Person) -> Unit,
) {
// Display list of people and an "add" button
// Call onAddPerson(newPerson) when user adds a person
}
@Composable
fun TheCaller() {
PeopleListScreen(people) { newPerson ->
_people.add(newPerson)
people = _people
}
}
(Some readers may realize the problem here, but this is quite common...)
What happens here? When people
is read, a Compose Snapshot
keeps track of that read value. When that same list is written to people
, the Snapshot system compares the new and old values and says "it's the same object; no change", and doesn't trigger recomposition.
If instead we use an immutable list, we're passing a different list instance each time:
var people by mutableStateOf(listOf(Person("Scott")))
@Composable
fun PeopleListScreen(
people: List<Person>,
onAddPerson: (Person) -> Unit,
) {
// Display list of people and an "add" button
// Call onAddPerson(newPerson) when user adds a person
}
@Composable
fun TheCaller() {
PeopleListScreen(people) { newPerson ->
people = people + newPerson
}
}
The listOf()
function creates and returns a List, and we set that list as the initial value in the people
bucket. When the new person is added, we call people = people + newPerson
which gets the existing list from the bucket, creates a new list with the newPerson
added at the end, and puts the new list in the bucket.
Now Compose will compare two different lists - one with newPerson
and one without, and trigger recomposition.