Skip to content

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.