State in Compose
Events, state, remember, rememberSavable, MutableStateList and viewModel
Full code in Git: https://github.com/nikitha2/ComposeTutorial.git
Linkedin: https://www.linkedin.com/in/nikitha-gullapalli/
In Introduction to compose, section Recomposition, I said “every time state changes @Composable functions recompose the UI”. Below are some questions that I got as a followup. Lets try and understand them in this article. We will be using the example from compose code lab here. Example discussed in article can be found in MediumArticleCode.kt and ComposeStateActivity in the git project.
- What is a State, Event and how does compose update UI?
- How to make a variable observable?
- Pattern to make recomposition efficient, reusable, Interceptable, Single source of truth and Decoupled?
- How to store state in a viewModel? What are the advantage?
1. What is a State, Event and how does compose update UI?
Scenario: Picture a screen with button “Add one”. Every time button is clicked, counter increments (var count) and displayed on the UI as shown below.
Event → User clicking on the button is an event in this scenario. Event can be defined as any input generated by the user or any part of the program, inside or outside the application (like sensors, endpoints, etc).
Update State → On user click event, count increases. As a result state of count changes.
User click event will awaken the onClick listener of button composable. onClick takes a block of commands as an input [() -> Unit]. Here the block {count+=1}
tells what needs to happen when button is clicked.
Button(onClick = { onclickListener }) {
Text(text = "Add one")
}
Update UI → for UI to recompose, count should be an observable variable. This will recompose all composable’s using count when count state changes. We will look at how to make a variable observable in the next section.
// Composable to have the above scenario with a button and counter.
@Composable
fun mediumArticleCode() {
var count = 0
val context= LocalContext.current
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
textAlign = TextAlign.Center,
text = "You've had $count glasses.",
)
Button(
onClick = {
Toast.makeText(context,"button clicked",Toast.LENGTH_SHORT).show()
count += 1
}
){
Text(text = "Add one")
}
}
}
When you run the code, you will notice that clicking “Add one” button does not increment the counter on UI. On button click toast is displayed, but counter is not updated. This shows when button is clicked, onClick lamda function is executed, but text is not updated. There are two problems here.
- count is not observable. On button click count value become count+1. Meaning state changes, but how will the composable function know it needs to observe count? So, how do we make count observable, so every time count state changes Text composable observes the value?
- As we know compose will re-compose UI on state change. Even if we make count observable. Wont recomposing the function set count to 0 ? This means we need to remember count’s value from previous composition. This way we can increment the way and show in the present composition.
Lets look at how we can achieve this in out next section.
2. How to make a variable observable?
1.State,
MutableState
and mutableStateListOf
type makes the variable observable by Compose. Compose keeps track of each composable that reads this value and schedules a recomposition when the variable state changes.
// mutableListOf makes count observable
var count : MutableState<Int> = mutableListOf(0)
On changing count type to MutableState
, count is now observable. However we notice complier is still throwing an error. Reason is now count
is observable, but every time UI is recomposed count is instantiated to 0. So you will not see any change on the UI.
Note: to change list into a list that is observable we can use toMutableStateList()
and apply the initial list as shown below
val list = remember {mutableStateListOf<ModalObject>().apply{ addAll(getList())) }}
2. We can use remember
composable inline function. A value calculated by remember
is stored in the Composition during the initial composition, and the stored value is kept across recompositions.
// mutableListOf makes count observable with initial value of 0
// remember will save count value across recompositions
var count : MutableState<Int> = remember { mutableStateOf(0) }
Awesome! you will notice that counter increments perfectly when you run the app. Every time button is clicked, Button onClick is invoked and count state changes (increments). As count is a variable of type MutableState
, Text
composable observes it and recomposes itself. Also as count value is saved across composition’s with remember
, count value increases with every click.
Note that count
will change to count.value
now because it is of type MutableState
. You can use the by
keyword to define count
as a var. Adding the delegate's getter and setter imports lets us read and mutate count
indirectly without explicitly referring to the MutableState’s value
property every time.
var count by remember { mutableStateOf(0) }
Now, try changing the configuration of the screen (rotate the phone). This forces the activity to recreate itself and counter value is lost.
3. Using rememberSaveable
instead of remember
will save count value across recompositions and also configuration changes.
Code will now look like this.
fun MediumArticleCode() {
var count by rememberSaveable { mutableStateOf(0) }
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
textAlign = TextAlign.Center,
text = "You've had $count glasses.",
)
Button(
onClick = { count += 1 }
) {
Text(text = "Add one")
}
}
}
3. Hoisting
Composable’s that use remember
or rememberSavable
to store objects contains internal state. These Composable’s are called stateful. These Composable’s are hard to reuse and harder to test. This is where hoisting helps.
State hoisting in Compose is a pattern of moving state to a composable’s caller to make a composable stateless. The general pattern for state hoisting in Jetpack Compose is to replace the state variable with two parameters:
- value: T — the current value to display
- onValueChange: (T) -> Unit — an event that requests the value to change, where T is the proposed new value. Where this value represents any state that could be modified.
Easy way to create a stateless composable is by using state hoisting.
- Stateless composable is a composable that doesn’t own any state, meaning it doesn’t hold or define or modify new state.
- Stateful composable is a composable that owns a piece of state that can change over time.
Important properties of Hoisting :
- Single source of truth: By moving state instead of duplicating it, we’re ensuring there’s only one source of truth. This helps avoid bugs.
- Shareable: Hoisted state can be shared with multiple composable’s.
- Interceptable: Callers to the stateless composable’s can decide to ignore or modify events before changing the state.
- Decoupled: The state for a stateless composable function can be stored anywhere. For example, in a ViewModel.
Lets call our stateless composable mediumArticleCodeStateless and stateful composable mediumArticleCodeStateful. mediumArticleCodeStateless will have minimum two parameter value and onValueChange. After moving all the state to mediumArticleCodeStateful, code will look as shown below.
/**
* @param count is the value
* @param onButtonClickListener is the onValueChange
* */
@Composable
fun mediumArticleCodeStateless(count: Int, onButtonClickListener: () -> Unit) {
// var count : MutableState<Int> = remember { mutableStateOf(0) }
// var count by remember { mutableStateOf(0) }
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
textAlign = TextAlign.Center,
text = "You've had $count glasses.",
)
Button(onClick = onButtonClickListener) {
Text(text = "Add one")
}
}
}
@Composable
fun mediumArticleCodeStateful() {
var count by rememberSaveable { mutableStateOf(0) }
mediumArticleCodeStateless(count, onButtonClickListener = { count += 1 })
}
Now say I have another composable mediumArticleCodeStateless2 that need count value. I can call in mediumArticleCodeStateful itself instead of duplicating it.
4. State in ViewModel
ViewModel provides UI state and access to bussiness logic in other layers of the app. UI can access live data from API’s, databases, etc through view model. This helps in keeping the app UI more dynamic, interactive and up to date.
Lets consider a List of items. We can use toMutableStateList()
to make list observable, but on running the app, you’ll notice state is not saved over configurations. How can we save the changes we make to the list across configurations? So far we used rememberSavable()
to save state across configurations and recompositions. But, it is not advisable to use rememberSavable()
to save large data across configurations.
val list = remember { getWellnessTasks().toMutableStateList() }
This is one such scenario we can use view Model. View model will save state across configurations. Also as a user we can delete/save/update or create an item in the list and we will want to POST this date to endpoints or save in database. View model will make this possible.
We can simply add a parameter to the stateful composable and pass it down the hierarchy as discussed in hoisting. But remember you still need to have a list that is of type MutableStateList in the view model. This is make the list observable.
@Composable
fun WellnessScreen(
modifier: Modifier = Modifier,
wellnessViewModel: WellnessViewModel = viewModel()
) {
// Pass it down the hirarcy
}
Refer to ComposeStateActivity in the github project for implementation.