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
and
rememberSaveable
APIs.
- How to work with lists and state: using the
mutableStateListOf
and
toMutableStateList
APIs.
- How to use
ViewModel
with Compose.
What you'll need
Recommended/Optional
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
and
app/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 the
app
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's
stringResource
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 our
WaterCounter
function:
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun WellnessScreen(modifier: Modifier = Modifier) {
WaterCounter(modifier)
}
- Open the
MainActivity.kt
. Remove the
Greeting
and the
DefaultPreview
composables. Call the newly created
WellnessScreen
composable inside the Activity's
setContent
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 both
WellnessTaskItem
and counter
Text
start to display.
- Press the X of
WellnessTaskItem
component (this causes another recomposition).
showTask
is now false, which means
WellnessTaskItem
isn't displayed anymore.
- Press the
Add one
button (another recomposition).
showTask
remembers you've closed
WellnessTaskItem
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
showing
count
, and all code related to
WellnessTaskItem
, are not invoked and leave the Composition.
showTask
is forgotten because the code location where remember
showTask
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 of
showTask
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 the
Checkbox
. Make sure that you hoist the
checked
state and the
onCheckedChange
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 variable
checkedState
and passes it to the stateless method of the same name. Don't worry about
onClose
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 a
LazyColumn
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 a
Column
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. Move
getWellnessTasks
method to this file to be able to use it. Create the list by calling
getWellnessTasks()
first and then using the extension function
toMutableStateList
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 parameter
onCloseTask
(receiving a
WellnessTask
to delete). Pass
onCloseTask
to the
WellnessTaskItem
.
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 the
onClose
lambda function as a parameter to the stateful
WellnessTaskItem
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 the
wellnessViewModel
ViewModel by calling
viewModel()
, as parameter of the Screen composable, so it can be replaced when testing this composable, and hoisted if required. Provide
WellnessTasksList
with the task list and remove function to the
onCloseTask
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's
onCheckedTask
by calling the ViewModel's
changeTaskChecked
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 the
onCheckedTask
lambda function parameter so that you can pass it down to the
WellnessTaskItem.
@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 that
checkedState
becomes
MutableState<Boolean>
instead of
Boolean
, 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
.
- JetNews
demonstrates the best practices explained in this codelab.
More documentation
Reference APIs