1. Before you begin
This codelab explains the core concepts related to using State in Jetpack Compose. It shows you how the app's state determines what is displayed in the UI, how Compose updates the UI when state changes by working with different APIs, how to optimize the structure of our composable functions, and using ViewModels in a Compose world.
Prerequisites
- Knowledge of Kotlin syntax.
- Basic understanding of Compose (you can start with the Jetpack Compose tutorial).
- Basic understanding of Architecture Component's
ViewModel
.
What you'll learn
- How to think about state and events in a Jetpack Compose UI.
- How Compose uses state to determine which elements to display on the screen.
- What state hoisting is.
- How stateful and stateless composable functions work.
- How Compose automatically tracks state with the
State<T>
API. - How memory and internal state work in a composable function: using the
remember
andrememberSaveable
APIs. - How to work with lists and state: using the
mutableStateListOf
andtoMutableStateList
APIs. - How to use
ViewModel
with Compose.
What you'll need
Recommended/Optional
- Read Thinking in Compose.
- Follow the Jetpack Compose basics codelab before this codelab. We will be doing a full recap of State in this codelab.
What you'll build
You will implement a simple Wellness app:
The app has two main functionalities:
- A water counter to track your water intake.
- A list of wellness tasks to do throughout the day.
For more support as you're walking through this codelab, check out the following code-along:
2. Get set up
Start a new Compose project
- To start a new Compose project, open Android Studio.
- If you're in the Welcome to Android Studio window, click Start a new Android Studio project. If you already have an Android Studio project open, select File > New > New Project from the menu bar.
- For a new project, choose Empty Activity from the available templates.
- Click Next and configure your project, calling it "BasicStateCodelab".
Make sure you select a minimumSdkVersion of at least API level 21, which is the minimum API Compose supports.
When you choose the Empty Compose Activity template, Android Studio sets up the following for you in your project:
- A
MainActivity
class configured with a composable function that displays some text on the screen. - The
AndroidManifest.xml
file, which defines your app's permissions, components, and custom resources. - The
build.gradle.kts
andapp/build.gradle.kts
files contain options and dependencies needed for Compose.
Solution to the codelab
You can get the solution code for the BasicStateCodelab
from GitHub:
$ git clone https://github.com/android/codelab-android-compose
Alternatively you can download the repository as a Zip file.
You'll find the solution code in the BasicStateCodelab
project. We recommend that you follow the codelab step by step at your own pace and check the solution if you need help. During the codelab, you are presented with snippets of code that you need to add to your project.
3. State in Compose
An app's "state" is any value that can change over time. This is a very broad definition and encompasses everything from a Room database to a variable in a class.
All Android apps display state to the user. A few examples of state in Android apps are:
- The most recent messages received in a chat app.
- The user's profile photo.
- The scroll position in a list of items.
Let's start writing your Wellness app.
For simplicity, during the codelab:
- You can add all Kotlin files in the root
com.codelabs.basicstatecodelab
package of theapp
module. In a production app, however, files should be logically structured in subpackages. - You'll hardcode all strings inline in snippets. In a real app, they should be added as string resources in the
strings.xml
file and referenced using Compose'sstringResource
API.
The first piece of functionality you need to build is a water counter to count the number of glasses of water you consume during the day.
Create a composable function called WaterCounter
that contains a Text
composable that displays the number of glasses. The number of glasses should be stored in a value called count
, which you can hardcode for now.
Create a new file WaterCounter.kt
with the WaterCounter
composable function, like this:
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
val count = 0
Text(
text = "You've had $count glasses.",
modifier = modifier.padding(16.dp)
)
}
Let's create a composable function that represents the whole screen, which will have two sections, the water counter and the list of wellness tasks. For now we'll just add our counter.
- Create a file
WellnessScreen.kt
, which represents the main screen, and call ourWaterCounter
function:
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun WellnessScreen(modifier: Modifier = Modifier) {
WaterCounter(modifier)
}
- Open the
MainActivity.kt
. Remove theGreeting
and theDefaultPreview
composables. Call the newly createdWellnessScreen
composable inside the Activity'ssetContent
block, like this:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
BasicStateCodelabTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
WellnessScreen()
}
}
}
}
}
- If you run the app now, you'll see our basic water counter screen with the hardcoded count of glasses of water.
The state of the WaterCounter
composable function is the variable count
. But having a static state is not very useful as it cannot be modified. To remedy this, you'll add a Button
to increase the count and track the amount of glasses of water you have throughout the day.
Any action that causes the modification of state is called an "event" and we'll learn more about this in the next section.
4. Events in Compose
We talked about state as any value that changes over time, for example, the last messages received in a chat app. But what causes the state to update? In Android apps, state is updated in response to events.
Events are inputs generated from outside or inside an application, such as:
- The user interacting with the UI by, for example, pressing a button.
- Other factors, such as sensors sending a new value, or network responses.
While the state of the app offers a description of what to display in the UI, events are the mechanism through which the state changes, resulting in changes to the UI.
Events notify a part of a program that something has happened. In all Android apps, there's a core UI update loop that goes like this:
- Event - An event is generated by the user or another part of the program.
- Update State - An event handler changes the state that is used by the UI.
- Display State - The UI is updated to display the new state.
Managing state in Compose is all about understanding how state and events interact with each other.
Now, add the button so that users can modify the state by adding more glasses of water.
Go to the WaterCounter
composable function to add the Button
below our label Text
. A Column
will help you vertically align the Text
with the Button
composables. You can move the external padding to the Column
composable and add some extra padding to the top of the Button
so it's separated from the Text.
The Button
composable function receives an onClick
lambda function - this is the event that happens when the button is clicked. You'll see more examples of lambda functions later.
Change count
to var
instead of val
so it becomes mutable.
import androidx.compose.material3.Button
import androidx.compose.foundation.layout.Column
@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
var count = 0
Text("You've had $count glasses.")
Button(onClick = { count++ }, Modifier.padding(top = 8.dp)) {
Text("Add one")
}
}
}
When you run the app and click the button, notice that nothing happens. Setting a different value for the count
variable won't make Compose detect it as a state change so nothing happens. This is because you haven't told Compose that it should redraw the screen (that is, "recompose" the composable function), when the state changes. You'll fix this in the next step.
5. Memory in a composable function
Compose apps transform data into UI by calling composable functions. We refer to the Composition as the description of the UI built by Compose when it executes composables. If a state change happens, Compose re-executes the affected composable functions with the new state, creating an updated UI—this is called recomposition. Compose also looks at what data an individual composable needs, so that it only recomposes components whose data has changed and skips those that are not affected.
To be able to do this, Compose needs to know what state to track, so that when it receives an update it can schedule the recomposition.
Compose has a special state tracking system in place that schedules recompositions for any composables that read a particular state. This lets Compose be granular and just recompose those composable functions that need to change, not the whole UI. This is done by tracking not only "writes" (that is, state changes), but also "reads" to the state.
Use Compose's State
and MutableState
types to make state observable by Compose.
Compose keeps track of each composable that reads State value
properties and triggers a recomposition when its value
changes. You can use the mutableStateOf
function to create an observable MutableState
. It receives an initial value as a parameter that is wrapped in a State
object, which then makes its value
observable.
Update WaterCounter
composable, so that count
uses mutableStateOf
API with 0
as initial value. As mutableStateOf
returns a MutableState
type, you can update its value
to update the state, and Compose will trigger a recomposition to those functions where its value
is read.
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
// Changes to count are now tracked by Compose
val count: MutableState<Int> = mutableStateOf(0)
Text("You've had ${count.value} glasses.")
Button(onClick = { count.value++ }, Modifier.padding(top = 8.dp)) {
Text("Add one")
}
}
}
As mentioned earlier, any changes to count
schedules a recomposition of any composable functions that read count
's value
automatically. In this case, WaterCounter
is recomposed whenever the button is clicked.
If you run the app now, you'll notice again that nothing happens yet!
Scheduling recompositions is working fine. However, when a recomposition happens, the variable count
is re-initialized back to 0, so we need a way to preserve this value across recompositions.
For this we can use the 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.
Usually remember
and mutableStateOf
are used together in composable functions.
There are a few equivalent ways to write this as shown in the Compose State documentation.
Modify WaterCounter
, surrounding the call to mutableStateOf
with the remember
inline composable function:
import androidx.compose.runtime.remember
@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
val count: MutableState<Int> = remember { mutableStateOf(0) }
Text("You've had ${count.value} glasses.")
Button(onClick = { count.value++ }, Modifier.padding(top = 8.dp)) {
Text("Add one")
}
}
}
Alternatively, we could simplify the usage of count
by using Kotlin's delegated properties.
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.
Now WaterCounter
looks like this:
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
var count by remember { mutableStateOf(0) }
Text("You've had $count glasses.")
Button(onClick = { count++ }, Modifier.padding(top = 8.dp)) {
Text("Add one")
}
}
}
You should pick the syntax that produces the easiest-to-read code in the composable you're writing.
Now let's examine what we've done so far:
- Defined a variable that we remember over time called
count
. - Created a text display where we tell the user the number we remembered.
- Added a button that increments the number we remembered whenever it's clicked.
This arrangement forms a data flow feedback loop with the user:
- The UI presents the state to the user (the current count is displayed as text).
- The user produces events that are combined with existing state to produce new state (clicking the button adds one to the current count)
Your counter is ready and working!
6. State driven UI
Compose is a declarative UI framework. Instead of removing UI components or changing their visibility when state changes, we describe how the UI is under specific conditions of state. As a result of a recomposition being called and UI updated, composables might end up entering or leaving the Composition.
This approach avoids the complexity of manually updating views as you would with the View system. It's also less error-prone, as you can't forget to update a view based on a new state, because it happens automatically.
If a composable function is called during the initial composition or in recompositions, we say it is present in the Composition. A composable function that is not called—for example, because the function is called inside an if statement and the condition is not met—-is absent from the Composition.
You can learn more about the lifecycle of composables in the documentation.
The output of the Composition is a tree-structure that describes the UI.
You can inspect the app layout generated by Compose using Android Studio's Layout inspector tool, which is what you'll do next.
To demonstrate this, modify your code to show UI based on state. Open WaterCounter
and show the Text
if the count
is greater than 0:
@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
var count by remember { mutableStateOf(0) }
if (count > 0) {
// This text is present if the button has been clicked
// at least once; absent otherwise
Text("You've had $count glasses.")
}
Button(onClick = { count++ }, Modifier.padding(top = 8.dp)) {
Text("Add one")
}
}
}
Run the app, and open Android Studio's Layout inspector tool by navigating to Tools > Layout Inspector.
You'll see a split screen: the components tree to the left and a preview of the app to the right.
Navigate the tree by tapping the root element BasicStateCodelabTheme
on the left of the screen. Expand the whole component tree by clicking the Expand all button.
Clicking on an element in the screen on the right navigates to the corresponding element of the tree.
If you press the Add one button on the app:
- Count increases to 1 and the state changes.
- A recomposition is called.
- Screen gets recomposed with the new elements.
When you examine the component tree with Android Studio's Layout inspector tool, now you see the Text
composable as well:
State drives which elements are present in the UI at a given moment.
Different parts of the UI can depend on the same state. Modify the Button
so it's enabled until count
is 10 and is then disabled (and you reach your goal for the day). Use the Button
's enabled
parameter to do this.
@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
...
Button(onClick = { count++ }, Modifier.padding(top = 8.dp), enabled = count < 10) {
...
}
Run the app now. Changes to state count
determine whether or not to show the Text
, and whether the Button
is enabled or disabled.
7. Remember in Composition
remember
stores objects in the Composition, and forgets the object if the source location where remember
is called is not invoked again during a recomposition.
To visualize this behavior, you'll implement the following piece of functionality in the app: when the user has had at least one glass of water, display a wellness task for the user to do, that they can also close. Because composables should be small and reusable, create a new composable called WellnessTaskItem
that displays the wellness task based on a string received as a parameter, along with a Close icon button.
Create a new file WellnessTaskItem.kt
, and add the following code. You'll use this composable function later in the codelab.
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.layout.padding
@Composable
fun WellnessTaskItem(
taskName: String,
onClose: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier, verticalAlignment = Alignment.CenterVertically
) {
Text(
modifier = Modifier.weight(1f).padding(start = 16.dp),
text = taskName
)
IconButton(onClick = onClose) {
Icon(Icons.Filled.Close, contentDescription = "Close")
}
}
}
The WellnessTaskItem
function receives a task description and an onClose
lambda function (just like the built-in Button
composable receives an onClick
).
WellnessTaskItem
looks like this:
To improve our app with more features, update WaterCounter
to show the WellnessTaskItem
when count
> 0.
When count
is greater than 0, define a variable showTask
that determines whether or not to show the WellnessTaskItem
and initialize it to true.
Add a new if statement to show WellnessTaskItem
if showTask
is true. Use the APIs you learned in the previous sections to make sure showTask
value survives recompositions.
@Composable
fun WaterCounter() {
Column(modifier = Modifier.padding(16.dp)) {
var count by remember { mutableStateOf(0) }
if (count > 0) {
var showTask by remember { mutableStateOf(true) }
if (showTask) {
WellnessTaskItem(
onClose = { },
taskName = "Have you taken your 15 minute walk today?"
)
}
Text("You've had $count glasses.")
}
Button(onClick = { count++ }, enabled = count < 10) {
Text("Add one")
}
}
}
Use the WellnessTaskItem
's onClose
lambda function, so that when the X button is pressed, the variable showTask
changes to false
and the task isn't shown anymore.
...
WellnessTaskItem(
onClose = { showTask = false },
taskName = "Have you taken your 15 minute walk today?"
)
...
Next, add a new Button
with the text "Clear water count" and place it beside the "Add one" Button
. A Row
can help align the two buttons. You can also add some padding to the Row
. When the "Clear water count" button is pressed, the variable count
resets back to 0.
Your WaterCounter
composable function should look like this:
import androidx.compose.foundation.layout.Row
@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
var count by remember { mutableStateOf(0) }
if (count > 0) {
var showTask by remember { mutableStateOf(true) }
if (showTask) {
WellnessTaskItem(
onClose = { showTask = false },
taskName = "Have you taken your 15 minute walk today?"
)
}
Text("You've had $count glasses.")
}
Row(Modifier.padding(top = 8.dp)) {
Button(onClick = { count++ }, enabled = count < 10) {
Text("Add one")
}
Button(
onClick = { count = 0 },
Modifier.padding(start = 8.dp)) {
Text("Clear water count")
}
}
}
}
When you run the app, your screen shows the initial state:
To the right, we have a simplified version of the components tree, which will help you analyze what is happening as state changes. count
and showTask
are remembered values.
Now you can follow these steps in the app:
- Press the Add one button. That increments
count
(this causes a recomposition) and bothWellnessTaskItem
and counterText
start to display.
- Press the X of
WellnessTaskItem
component (this causes another recomposition).showTask
is now false, which meansWellnessTaskItem
isn't displayed anymore.
- Press the Add one button (another recomposition).
showTask
remembers you've closedWellnessTaskItem
in the next recompositions if you keep adding glasses.
- Press the Clear water count button to reset
count
to 0 and cause a recomposition.Text
showingcount
, and all code related toWellnessTaskItem
, are not invoked and leave the Composition.
showTask
is forgotten because the code location where remembershowTask
is called was not invoked. You're back to the first step.
- Press the Add one button making
count
greater than 0 (recomposition).
WellnessTaskItem
composable displays again, because the previous value ofshowTask
was forgotten when it left the Composition above.
What if we require showTask
to persist after count
goes back to 0, longer than what remember
allows (that is, even if the code location where remember
is called is not invoked during a recomposition)? We'll explore how to fix these scenarios and more examples in the next sections.
Now that you understand how the UI and state are reset when they leave the Composition, clear your code and go back to the WaterCounter
you had at the beginning of this section:
@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
var count by remember { mutableStateOf(0) }
if (count > 0) {
Text("You've had $count glasses.")
}
Button(onClick = { count++ }, Modifier.padding(top = 8.dp), enabled = count < 10) {
Text("Add one")
}
}
}
8. Restore state in Compose
Run the app, add some glasses of water to the counter, and then rotate your device. Make sure you have the device's Auto-rotate setting on.
Because Activity is recreated after a configuration change (in this case, orientation), the state that was saved is forgotten: the counter disappears as it goes back to 0.
The same happens if you change language, switch between dark and light mode, or any other configuration change that makes Android recreate the running Activity.
While remember
helps you retain state across recompositions, it's not retained across configuration changes. For this, you must use rememberSaveable
instead of remember
.
rememberSaveable
automatically saves any value that can be saved in a Bundle
. For other values, you can pass in a custom saver object. For more information on Restoring state in Compose, check out the documentation.
In WaterCounter
, replace remember
with rememberSaveable
:
import androidx.compose.runtime.saveable.rememberSaveable
@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
...
var count by rememberSaveable { mutableStateOf(0) }
...
}
Run the app now and try some configuration changes. You should see the counter is properly saved.
Activity recreation is just one of the use cases of rememberSaveable
. We'll explore another use case later while working with lists.
Consider whether to use remember
or rememberSaveable
depending on your app's state and UX needs.
9. State hoisting
A composable that uses remember
to store an object contains internal state, which makes the composable stateful. This is useful in situations where a caller doesn't need to control the state and can use it without having to manage the state themselves. However, composables with internal state tend to be less reusable and harder to test.
Composables that don't hold any state are called stateless composables. An easy way to create a stateless composable is by using state hoisting.
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 with a new value T
where this value represents any state that could be modified.
State that is hoisted this way has some important properties:
- 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 composables.
- Interceptable: Callers to the stateless composables 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.
Try to implement this for the WaterCounter
so it can benefit from all of the above.
Stateful vs Stateless
When all state can be extracted from a composable function the resulting composable function is called stateless.
Refactor WaterCounter
composable by splitting it into two parts: stateful and stateless Counter.
The role of the StatelessCounter
is to display the count
and call a function when you increment the count
. To do this, follow the pattern described above and pass the state, count
(as a parameter to the composable function), and a lambda (onIncrement
), that is called when the state needs to be incremented. StatelessCounter
looks like this:
@Composable
fun StatelessCounter(count: Int, onIncrement: () -> Unit, modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
if (count > 0) {
Text("You've had $count glasses.")
}
Button(onClick = onIncrement, Modifier.padding(top = 8.dp), enabled = count < 10) {
Text("Add one")
}
}
}
StatefulCounter
owns the state. That means that it holds the count
state and modifies it when calling the StatelessCounter
function.
@Composable
fun StatefulCounter(modifier: Modifier = Modifier) {
var count by rememberSaveable { mutableStateOf(0) }
StatelessCounter(count, { count++ }, modifier)
}
Good job! You hoisted count
from StatelessCounter
to StatefulCounter
.
You can plug this into your app and update WellnessScreen
with the StatefulCounter
:
@Composable
fun WellnessScreen(modifier: Modifier = Modifier) {
StatefulCounter(modifier)
}
As mentioned, state hoisting has some benefits. We'll explore variations of this code to explain some of them, you don't need to copy the following snippets in your app.
- Your stateless composable can now be reused. Take for instance the following example.
To count glasses of water and of juice you remember the waterCount
and the juiceCount
, but use the same StatelessCounter
composable function to display two different independent states.
@Composable
fun StatefulCounter() {
var waterCount by remember { mutableStateOf(0) }
var juiceCount by remember { mutableStateOf(0) }
StatelessCounter(waterCount, { waterCount++ })
StatelessCounter(juiceCount, { juiceCount++ })
}
If juiceCount
is modified then StatefulCounter
is recomposed. During recomposition, Compose identifies which functions read juiceCount
and triggers recomposition of only those functions.
When the user taps to increment juiceCount
, StatefulCounter
recomposes, and so does the StatelessCounter
that reads juiceCount
. But the StatelessCounter
that reads waterCount
is not recomposed.
- Your stateful composable function can provide the same state to multiple composable functions.
@Composable
fun StatefulCounter() {
var count by remember { mutableStateOf(0) }
StatelessCounter(count, { count++ })
AnotherStatelessMethod(count, { count *= 2 })
}
In this case, if the count is updated by either StatelessCounter
or AnotherStatelessMethod
, everything is recomposed, which is expected.
Because hoisted state can be shared, be sure to pass only the state that the composables need to avoid unnecessary recompositions, and to increase reusability.
To read more about state and state hoisting, check out the Compose State documentation.
10. Work with lists
Next, add the second feature of your app, the list of wellness tasks. You can perform two actions with items on the list:
- Check list items to mark the task as completed.
- Remove tasks from the list you're not interested in completing.
Setup
- First, modify the list item. You can reuse the
WellnessTaskItem
from the Remember in Composition section, and update it to contain theCheckbox
. Make sure that you hoist thechecked
state and theonCheckedChange
callback to make the function stateless.
The WellnessTaskItem
composable for this section should look like this:
import androidx.compose.material3.Checkbox
@Composable
fun WellnessTaskItem(
taskName: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier, verticalAlignment = Alignment.CenterVertically
) {
Text(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp),
text = taskName
)
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange
)
IconButton(onClick = onClose) {
Icon(Icons.Filled.Close, contentDescription = "Close")
}
}
}
- In the same file, add a stateful
WellnessTaskItem
composable function that defines a state variablecheckedState
and passes it to the stateless method of the same name. Don't worry aboutonClose
for now, you can pass an empty lambda function.
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@Composable
fun WellnessTaskItem(taskName: String, modifier: Modifier = Modifier) {
var checkedState by remember { mutableStateOf(false) }
WellnessTaskItem(
taskName = taskName,
checked = checkedState,
onCheckedChange = { newValue -> checkedState = newValue },
onClose = {}, // we will implement this later!
modifier = modifier,
)
}
- Create a file
WellnessTask.kt
to model a task that contains an ID and a label. Define it as a data class.
data class WellnessTask(val id: Int, val label: String)
- For the list of tasks itself, create a new file named
WellnessTasksList.kt
and add a method that generates some fake data:
fun getWellnessTasks() = List(30) { i -> WellnessTask(i, "Task # $i") }
Note that in a real app, you get your data from your data layer.
- In
WellnessTasksList.kt
, add a composable function that creates the list. Define aLazyColumn
and items from the list method you created. Check out the Lists documentation if you need help.
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.runtime.remember
@Composable
fun WellnessTasksList(
modifier: Modifier = Modifier,
list: List<WellnessTask> = remember { getWellnessTasks() }
) {
LazyColumn(
modifier = modifier
) {
items(list) { task ->
WellnessTaskItem(taskName = task.label)
}
}
}
- Add the list to
WellnessScreen
. Use aColumn
to help vertically align the list with the counter you already have.
import androidx.compose.foundation.layout.Column
@Composable
fun WellnessScreen(modifier: Modifier = Modifier) {
Column(modifier = modifier) {
StatefulCounter()
WellnessTasksList()
}
}
- Run the app and give it a try! You should now be able to check tasks but not delete them. You'll implement that in a later section.
Restore item state in LazyList
Take a closer look now at some things in the WellnessTaskItem
composables.
checkedState
belongs to each WellnessTaskItem
composable independently, like a private variable. When checkedState
changes, only that instance of WellnessTaskItem
gets recomposed, not all WellnessTaskItem
instances in the LazyColumn
.
Try it out by following these steps:
- Check any element at the top of this list (for example elements 1 and 2).
- Scroll to the bottom of the list so that they're off the screen.
- Scroll back to the top to the items you checked before.
- Notice that they are unchecked.
There is an issue, as you saw in a previous section, that when an item leaves the Composition, state that was remembered is forgotten. For items on a LazyColumn
, items leave the Composition entirely when you scroll past them and they're no longer visible.
How do you fix this? Once again, use rememberSaveable
. Your state will survive the activity or process recreation using the saved instance state mechanism. Thanks to how rememberSaveable
works together with the LazyList
, your items are able to also survive leaving the Composition.
Just replace remember
with rememberSaveable
in your stateful WellnessTaskItem
, and that's it:
import androidx.compose.runtime.saveable.rememberSaveable
var checkedState by rememberSaveable { mutableStateOf(false) }
Common patterns in Compose
Notice the implementation of LazyColumn
:
@Composable
fun LazyColumn(
...
state: LazyListState = rememberLazyListState(),
...
The composable function rememberLazyListState
creates an initial state for the list using rememberSaveable
. When the Activity is recreated, the scroll state is maintained without you having to code anything.
Many apps need to react and listen to scroll position, item layout changes, and other events related to the list's state. Lazy components, like LazyColumn
or LazyRow
, support this use case through hoisting the LazyListState
. You can learn more about this pattern in the documentation for state in lists.
Having a state parameter with a default value provided by a public rememberX
function is a common pattern in built-in composable functions. Another example can be found in BottomSheetScaffold
, which hoists state using rememberBottomSheetScaffoldState
.
11. Observable MutableList
Next, to add the behavior of removing a task from our list, the first step is to make your list a mutable list.
Using mutable objects for this, such as ArrayList<T>
or mutableListOf,
won't work. These types won't notify Compose that the items in the list have changed and schedule a recomposition of the UI. You need a different API.
You need to create an instance of MutableList
that is observable by Compose. This structure lets Compose track changes to recompose the UI when items are added or removed from the list.
Start by defining our observable MutableList
. The extension function toMutableStateList()
is the way to create an observable MutableList
from an initial mutable or immutable Collection
, such as List
.
Alternatively, you could also use the factory method mutableStateListOf
to create the observable MutableList
and then add the elements for your initial state.
- Open
WellnessScreen.kt
file. MovegetWellnessTasks
method to this file to be able to use it. Create the list by callinggetWellnessTasks()
first and then using the extension functiontoMutableStateList
you learned before.
import androidx.compose.runtime.remember
import androidx.compose.runtime.toMutableStateList
@Composable
fun WellnessScreen(modifier: Modifier = Modifier) {
Column(modifier = modifier) {
StatefulCounter()
val list = remember { getWellnessTasks().toMutableStateList() }
WellnessTasksList(list = list, onCloseTask = { task -> list.remove(task) })
}
}
private fun getWellnessTasks() = List(30) { i -> WellnessTask(i, "Task # $i") }
- Modify
WellnessTasksList
composable function by removing the list's default value, because the list is hoisted to the screen level. Add a new lambda function parameteronCloseTask
(receiving aWellnessTask
to delete). PassonCloseTask
to theWellnessTaskItem
.
There's one more change you need to make. The items
method receives a key
parameter. By default, each item's state is keyed against the position of the item in the list.
In a mutable list, this causes issues when the data set changes, since items that change position effectively lose any remembered state.
You can easily fix this by using the id
of each WellnessTaskItem
as the key for each item.
To learn more about item keys in a list, check out the documentation.
WellnessTasksList
will look like this:
@Composable
fun WellnessTasksList(
list: List<WellnessTask>,
onCloseTask: (WellnessTask) -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(modifier = modifier) {
items(
items = list,
key = { task -> task.id }
) { task ->
WellnessTaskItem(taskName = task.label, onClose = { onCloseTask(task) })
}
}
}
- Modify
WellnessTaskItem
: add theonClose
lambda function as a parameter to the statefulWellnessTaskItem
and call it.
@Composable
fun WellnessTaskItem(
taskName: String, onClose: () -> Unit, modifier: Modifier = Modifier
) {
var checkedState by rememberSaveable { mutableStateOf(false) }
WellnessTaskItem(
taskName = taskName,
checked = checkedState,
onCheckedChange = { newValue -> checkedState = newValue },
onClose = onClose,
modifier = modifier,
)
}
Good job! The functionality is complete, and deleting an item from the list works.
If you click the X in each row, the events go all the way up to the list that owns the state, removing the item from the list and causing Compose to recompose the screen.
If you try to use rememberSaveable()
to store the list in WellnessScreen
, you'll get a runtime exception:
This error tells you that you need to provide a custom saver. However, you shouldn't be using rememberSaveable
to store large amounts of data or complex data structures that require lengthy serialization or deserialization.
Similar rules apply when working with Activity's onSaveInstanceState
; you can find more information in the Save UI states documentation. If you want to do this, you need an alternative storing mechanism. You can learn more about different options for preserving UI state in the documentation.
Next, we'll look at ViewModel's role as a holder for the app's state.
12. State in ViewModel
The screen, or UI state, indicates what should display on the screen (for example, the list of tasks). This state is usually connected with other layers of the hierarchy because it contains application data.
While the UI state describes what to show on the screen, the logic of an app describes how the app behaves and should react to state changes. There are two types of logic: the UI behavior or UI logic, and the business logic.
- The UI logic relates to how to display state changes on the screen (for example, the navigation logic or showing snackbars).
- The business logic is what to do with state changes (for example making a payment or storing user preferences). This logic is usually placed in the business or data layers, never in the UI layer.
ViewModels provide the UI state and access to the business logic located in other layers of the app. Additionally, ViewModels survive configuration changes, so they have a longer lifetime than the Composition. They can follow the lifecycle of the host of Compose content—that is, activities, fragments, or the destination of a Navigation graph if you're using Compose Navigation.
To learn more about architecture and UI layer, check the UI layer documentation.
Migrate the list and remove method
While the previous steps showed you how to manage the state directly in the Composable functions, it's a good practice to keep the UI logic and business logic separated from the UI state and migrate it to a ViewModel.
Let's migrate the UI state, the list, to your ViewModel and also start extracting business logic into it.
- Create a file
WellnessViewModel.kt
to add your ViewModel class.
Move your "data source" getWellnessTasks()
to the WellnessViewModel
.
Define an internal _tasks
variable, using toMutableStateList
as you did before, and expose tasks
as a list, so it's not modifiable from outside the ViewModel.
Implement a simple remove
function that delegates to the list's builtin remove function.
import androidx.compose.runtime.toMutableStateList
import androidx.lifecycle.ViewModel
class WellnessViewModel : ViewModel() {
private val _tasks = getWellnessTasks().toMutableStateList()
val tasks: List<WellnessTask>
get() = _tasks
fun remove(item: WellnessTask) {
_tasks.remove(item)
}
}
private fun getWellnessTasks() = List(30) { i -> WellnessTask(i, "Task # $i") }
- We can access this ViewModel from any composable by calling the
viewModel()
function.
To use this function, open the app/build.gradle.kts
file, add the following library, and sync the new dependencies in Android Studio:
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:{latest_version}")
Use version 2.6.2
when working with Android Studio Giraffe. Else check the latest version of the library here.
- Open the
WellnessScreen
. Instantiate thewellnessViewModel
ViewModel by callingviewModel()
, as parameter of the Screen composable, so it can be replaced when testing this composable, and hoisted if required. ProvideWellnessTasksList
with the task list and remove function to theonCloseTask
lambda.
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun WellnessScreen(
modifier: Modifier = Modifier,
wellnessViewModel: WellnessViewModel = viewModel()
) {
Column(modifier = modifier) {
StatefulCounter()
WellnessTasksList(
list = wellnessViewModel.tasks,
onCloseTask = { task -> wellnessViewModel.remove(task) })
}
}
viewModel()
returns an existing ViewModel
or creates a new one in the given scope. The ViewModel instance is retained as long as the scope is alive. For example, if the composable is used in an activity, viewModel()
returns the same instance until the activity is finished or the process is killed.
And that's it! You've integrated the ViewModel with part of the state and business logic with your screen. Since the state is kept outside of the Composition and stored by the ViewModel, mutations to the list survive configuration changes.
ViewModel won't automatically persist the state of the app in any scenario (for example, for system-initiated process death). For detailed information about persisting your app's UI state check the documentation.
Migrate the checked state
The last refactor is to migrate the checked state and logic to the ViewModel. This way the code is simpler and more testable, with all state managed by the ViewModel.
- First, modify the
WellnessTask
model class so that it's able to store the checked state and set false as default value.
data class WellnessTask(val id: Int, val label: String, var checked: Boolean = false)
- In the ViewModel, implement a method
changeTaskChecked
that receives a task to modify with a new value for the checked state.
class WellnessViewModel : ViewModel() {
...
fun changeTaskChecked(item: WellnessTask, checked: Boolean) =
_tasks.find { it.id == item.id }?.let { task ->
task.checked = checked
}
}
- In
WellnessScreen
, provide the behavior for the list'sonCheckedTask
by calling the ViewModel'schangeTaskChecked
method. The functions should now look like this:
@Composable
fun WellnessScreen(
modifier: Modifier = Modifier,
wellnessViewModel: WellnessViewModel = viewModel()
) {
Column(modifier = modifier) {
StatefulCounter()
WellnessTasksList(
list = wellnessViewModel.tasks,
onCheckedTask = { task, checked ->
wellnessViewModel.changeTaskChecked(task, checked)
},
onCloseTask = { task ->
wellnessViewModel.remove(task)
}
)
}
}
- Open
WellnessTasksList
and add theonCheckedTask
lambda function parameter so that you can pass it down to theWellnessTaskItem.
@Composable
fun WellnessTasksList(
list: List<WellnessTask>,
onCheckedTask: (WellnessTask, Boolean) -> Unit,
onCloseTask: (WellnessTask) -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier
) {
items(
items = list,
key = { task -> task.id }
) { task ->
WellnessTaskItem(
taskName = task.label,
checked = task.checked,
onCheckedChange = { checked -> onCheckedTask(task, checked) },
onClose = { onCloseTask(task) }
)
}
}
}
- Clean up
WellnessTaskItem.kt
file. We no longer need a stateful method, as the CheckBox state will be hoisted to the List level. The file only has this composable function:
@Composable
fun WellnessTaskItem(
taskName: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier, verticalAlignment = Alignment.CenterVertically
) {
Text(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp),
text = taskName
)
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange
)
IconButton(onClick = onClose) {
Icon(Icons.Filled.Close, contentDescription = "Close")
}
}
}
- Run the app and try to check any task. Notice that checking any task doesn't quite work yet.
This is because what Compose is tracking for the MutableList
are changes related to adding and removing elements. This is why deleting works. But it's unaware of changes in the row item values (checkedState
in our case), unless you tell it to track them too.
There are two ways to fix this:
- Change our data class
WellnessTask
so thatcheckedState
becomesMutableState<Boolean>
instead ofBoolean
, which causes Compose to track an item change. - Copy the item you're about to mutate, remove the item from your list and re-add the mutated item to the list, which causes Compose to track that list change.
There are pros and cons to both approaches. For example, depending on your implementation of the list you're using, removing and reading the element might be costly.
So let's say, you want to avoid potentially expensive list operations, and make checkedState
observable as it's more efficient and Compose-idiomatic.
Your new WellnessTask
could look like this:
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
data class WellnessTask(val id: Int, val label: String, val checked: MutableState<Boolean> = mutableStateOf(false))
As you saw before, you can use delegated properties, which results in a simpler usage of the variable checked
for this case.
Change WellnessTask
to be a class instead of a data class. Make WellnessTask
receive an initialChecked
variable with default value false
in the constructor, then we can initialize the checked
variable with the factory method mutableStateOf
and taking initialChecked
as default value.
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
class WellnessTask(
val id: Int,
val label: String,
initialChecked: Boolean = false
) {
var checked by mutableStateOf(initialChecked)
}
That's it! This solution works, and all changes survive recomposition and configuration changes!
Testing
Now that the business logic is refactored into the ViewModel instead of coupled inside composable functions, unit testing is much simpler.
You can use instrumented testing to verify the correct behavior of your Compose code and that UI state is working properly. Consider taking the codelab Testing in Compose to learn how to test your Compose UI.
13. Congratulations
Good job! You've successfully completed this codelab and learned all the basic APIs to work with state in a Jetpack Compose app!
You learned how to think about state and events to extract stateless composables in Compose, and how Compose uses state updates to drive change in the UI.
What's next?
Check out the other codelabs on the Compose pathway.
Sample apps
- JetNews demonstrates the best practices explained in this codelab.
More documentation
- Thinking in Compose
- State and Jetpack Compose
- Unidirectional data flow in Jetpack Compose
- Restoring state in Compose
- ViewModel Overview
- Compose and other libraries