Jetpack Compose state deconstructed

Jetpack Compose state deconstructed

States are one of the most important concepts in Jetpack Compose, knowing how to use them is an asset for anyone who wants to use this tool. In this article, we will try to dissect this concept in order to understand it, no matter if you are a novice or not.

With the view system (XML), each user interface element is an object containing an internal state that can be updated by calling a setter or by modifying a property.

Let’s take the example of an application that displays a number that is supposed to be incremented when a button is clicked.

val textView : TextView = findViewById(R.id.tV)
val button : Button = findViewById(R.id.btn)
var count = 0

textView.text = count

button.setOnClickListener {
    count++
    textView.text = count
}

The value of the TextView is an internal state of this element, and it can only be modified by calling a setter on this object with the new value.

With Jetpack compose everything becomes different, composables are functions that receive as arguments the data they have to use to render the UI, and when a data changes, the functions are executed again with the new arguments (we talk about recomposition).

Jetpack compose is quite smart ! It only re-executes the composable that use the value that has been modified, this is “Smart recomposition”.

For our example, the Jetpack compose version of our interface will look like this

@Composable
fun Counter() {
    var count = 0
    Text("$count")
    Button(onClick = { count++ }) {
        Text(text = "Click Me")
    }
}

But the above code won’t work (and you’ll understand why 👨🏾‍💻).

You should note that not all values will initiate a recomposition when they change, only states can do that. A state is just an observable of type State<T>.

Create a state in compose

To create a state there is nothing complicated, you just have to call the function mutableStateOf which returns an observable of type MutableState<T> whose signature is

interface MutableState<T> : State<T> {
    override var value: T
}

mutableStateOf takes the initial value between the brackets, and you only need to call the value property to access or change the value.

Now here is what our code will look like

@Composable
fun Counter() {
    var count = mutableStateOf(0)
    Text("${count.value}")
    Button(onClick = { count.value++ }) {
        Text(text = "Click Me")
    }
}

This code will not work properly either.

You have to know that recomposition can happen at any time, the Counter composable can recompose at any time, and when you are going to modify the count variable, the Counter function can be recomposed, and it will run again which means that all the code inside will be re-executed as well, as a consequence count will be reset to 0.

If you copy this code into Android Studio, it will display an error telling you the same thing I just told you.

So how do you remember a state even when the function is re-executed ?

By using remember !

Remembering a state

The remember function allows a composable to keep a value in memory across recompositions.

In our case, it will prevent count from being reset to 0 and keep the incremented value.

@Composable
fun Counter() {
    var count = remember { mutableStateOf(0) }
    Text("${count.value}")
    Button(onClick = { count.value++ }) {
        Text(text = "Click Me")
    }
}

This time our code works ! 🎉

We can make it even more concise by using the delegate with by, this will save us from doing .value every time we want to use count.

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }
    Text("$count")
    Button(onClick = { count++ }) {
        Text(text = "Click Me")
    }
}

You will need to make the following imports to use by

import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue

While remember helps you retain state across recompositions, the state is not retained across configuration changes (such as screen rotation).For this, you must use rememberSaveable.

rememberSaveable automatically saves any value that can be saved in a Bundle. For other values, you can pass in a custom saver object.

Other types of states

In order for Jetpack compose to use a value as a state, you need to turn it into a State<T> and compose comes with functions that allow you to convert certain types of observable into a state.

You can convert LiveData, Flow and RxJava2.

LiveData

val value: String by liveData.observeAsState("initial")

Flow

val value: String by stateFlow.collectAsState()

RxJava2

val value by completable.subscribeAsState()

If you use custom Observables, you can create function extensions to turn them into state

Stateful vs stateless

Stateful Composable

A composable like Counter is said to be stateful because it contains a remember with a state inside, the function that will call it will not have steel to this state

here is an example

@Composable
fun HomeScreen() {
    Counter()
}

HomeScreen has no access to the count value, so it can neither control nor modify it, since Counter locks it inside itself.

In our case above, everything works perfectly, but you should know that stateful composables are not always ideal. They are not easily reusable, especially when the caller must have control over the state, for that you need a stateless composable.

Stateless composable

A stateless composable is just a composable that does not contain the state initialization inside it, but rather receives it as a parameter via the function that calls it. The best example of a stateless composable is the compose TextField

@Composable
fun MyScreen() {
    var text by remember { mutableStateOf("") }
    TextField(
        value = text,
        onValueChange = { newText ->
            text = newText
        }
    )
}

The Textfield composable doesn't contain the text that is entered, but it receives it as an argument, and it is the caller that controls the text value. And if text field wants to change the text, it just has to use a callback.

This makes TextField easily reusable and testable, as the composable that call it can adapt its behavior to their specifications.

The easiest way to create a stateless composable is to hoist the state.

State hoisting

When we talk about states in Jetpack compose, there is a pretty important concept that you have to know, it’s the state hoisting.

Let’s go back to our counter example and try to hoist its state.

@Composable
fun Counter(count: Int) {
    Text("$count")
    Button(onClick = { count++ }) {
        Text(text = "Click Me")
    }
}

This time, instead of creating a state inside, we will pass it as a parameter.

This code won’t work because of *count++*, because the value received as a parameter can't be modified inside the function (it's a *val*)

So to achieve this, we will pass a second parameter, which will be a function.

@Composable
fun Counter(count: Int, onIncrement: () -> Unit) {
    Text("$count")
    Button(onClick = { onIncrement() }) {
        Text(text = "Click Me")
    }
}

Now the caller can have a control on the state.

@Composable
fun HomeScreen() {
    var count by remember { mutableStateOf(0) }
    Counter(count = count, onIncrement = { count++ } )
}

Our counter is now reusable, and the function that calls it can completely control the state. We can even have a counter that increments by 2.

@Composable
fun Screen2() {
    var count by remember { mutableStateOf(0) }
    Counter(count = count, onIncrement = { count += 2 } )
}

The Preview of HomeScreen and Screen2

Note: you can pass several functions as arguments to a composable, and a function can change its form depending on the use case.

For example, we can transform onIncrement: () → Unit into onIncrement: (Int) → Unit in order to increment the number inside the Counter and bring up the new value.

@Composable
fun Counter(count: Int,onIncrement: (Int) -> Unit) {
    Text("${count}")
    Button(onClick = { onIncrement(count + 1) }) {
        Text(text = "Click Me")
    }
}

@Composable
fun HomeScreen() {
    var count by remember { mutableStateOf(0) }
    Counter(count = count, onIncrement = { count = it } )
}

This example corresponds to what is done in the compose TextField with the onValueChange: (String) -> Unit function.

Where to go from here?

States are a very important concept with Jetpack Compose or the declarative paradigm in general. Misusing them can also affect your app performance.

Below are some links to resources that will be useful for you further on.

https://developer.android.com/jetpack/compose/mental-model