Chapters

Hide chapters

Jetpack Compose by Tutorials

First Edition · Android 11 · Kotlin 1.4 · Android Studio Canary

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

7. Managing State in Compose
Written by Denis Buketa

Great job on completing the first two chapters of this section. Now you know the basic principles of composing a UI and making it beautiful.

In this chapter, you’ll change your focus from the UI of JetNotes to making it functional. To make any app functional, you need to know how to manage state, which is the topic of this chapter.

In this chapter, you’ll learn:

  • What state is.
  • What unidirectional data flow is.
  • How to think about state and events when creating stateless composables.
  • How to use ViewModel and LiveData from Android Architecture Components to manage state in Compose.
  • How to add functionality to the Notes screen.

Get ready to dive in by taking a deeper look at what state is and why it’s critical for your app.

Understanding state

Before you can understand the state management theory, you need to define what state is.

At its core, every app works with specific values that can change. For example, JetNotes manages notes, and users can make changes to the list of notes. They can:

  • Add new notes.
  • Delete current notes.
  • Change a note.
  • Complete a note.

State is any value that can change over time. Those values can include anything from an entry in a database to a property of a class. And as the state changes, you need to update the UI to reflect those changes.

UI update loop

When you think about how users interact with Android apps, you can say that it’s like having a conversation. Users communicate through events like clicking, dragging and speaking while the app responds by displaying the app’s state.

Events are inputs generated outside the app, while the state is the result of the app’s reaction to an event. In between, you have the logic to update the state.

UI Update Loop
UI Update Loop

These three concepts form the UI update loop:

  • Event: Input generated by the user or another part of the program.
  • Update state: An event handler that reacts to the event and updates the state.
  • Display state: The UI updates and displays the new state.

This is how all Android apps work. Understanding this concept is key to understanding how Compose manages state.

Handling state with Android UI Toolkit

Before going further, remind yourself how the current Android UI Toolkit manages state.

In Chapter 1, “Developing UI in Android”, you had the chance to explore the data flow between the UI and the business logic for a basic Android component — a Spinner.

There, you saw that it’s difficult to build a UI that represents the model — or a state, in this case — if the UI also owns and manages state.

That kind of design has some problems, including:

  • Testing: It’s difficult to test views like Activity or Fragment if the state of the UI is mixed in with them.
  • Partial state updates: If the screen has a lot of events, it’s easy to forget to update a part of the state, which can result in an incorrect UI.
  • Partial UI updates: Whenever the state changes, you have to update the UI manually. The more things you have to update, the easier it is to forget something, once again resulting in an incorrect UI.
  • Code complexity: When using this pattern, it’s difficult to extract some of the logic. In the long run, the code tends to become difficult to read and understand.
  • No single source of truth: Because both the UI and the model own the state, you have to make sure that they’re in sync.
  • Update responsibility: You don’t always know if you’re the one changing the View state, or if the event came from the user.

Keep this in mind as you learn about unidirectional data flow and how it can help.

Handling state with unidirectional data flow

In the previous Spinner example, the data flow had multiple directions it could come from and multiple directions it could go to, depending on trigger events and UI updates it reflected. This means it’s hard to keep everything in sync and its hard to know where the change is coming from at all times.

Unidirectional data flow on the other hand is a concept where both the state changes and UI updates have only one direction, as the name states. This means that state change events can only come from one source, usually from user interactions, and UI updates can come only from the state manager, the event handler or the model, however you want to refer to it.

Unidirectional data flow isn’t a new concept in programming. It’s well-established that it’s a good idea to decouple components that display state in the UI from the parts of the app that store and change state.

Compose was built with unidirectional data flow in mind.

Unidirectional Data Flow
Unidirectional Data Flow

The key concept here is that state flows down and events flow up, as the image above shows.

Another key concept is that the UI observes the state. Every time there’s new state, the UI displays it.

Here’s how the UI update loop for an app that uses unidirectional data flow looks:

  • Event: A UI component generates input and passes it up.
  • Update state: An event handler may or may not change the state. For some UI components, the new state is already in the correct format, so it doesn’t need to change.
  • Display state: The UI observes the state. Upon creation, the new state is passed down to the UI that displays it.

Even though Compose didn’t have a built-in Spinner at the time of this writing, you can reimagine how you used one in Chapter 1, “Developing UI in Android” with the unidirectional data flow in mind.

Unidirectional Data Flow
Unidirectional Data Flow

In the figure, you can see two distinct parts of the unidirectional data flow:

  1. The UI, represented by the spinner.
  2. The state, represented by State properties.

The Spinner observes the state and can generate events. An event handler may or may not update the state when the new event comes. When the state changes, the Spinner is aware of and displays that change.

Just as when you worked with it in the previous example, the user can interact with the Spinner — the main difference now is how you interact with it in the code. In code, you don’t interact directly with the Spinner; you only update the state. Since the Spinner observes that state, the UI updates correctly when the state changes.

Following this pattern when using Jetpack Compose has several advantages:

  • Testability: Since the UI is decoupled from the state, you can test each component in isolation.

  • State encapsulation: Because state can only be updated in one place, you’re less likely to create inconsistent states.

  • UI consistency: Since your UI observes the state, the UI immediately reflects all state updates.

  • Single source of truth: The UI and the model no longer share the state. State is only present in one place, which is now the single source of truth.

  • Clear responsibility for updates: The UI component can only generate new events and only the user can interact with it. Within the code, you interact with the state itself, not the UI component.

Good! Now that you know the basic principles of state management that Jetpack Compose is built upon, you’re ready to get your hands dirty, by adding your first feature to JetNotes. :]

Compose & ViewModel

As mentioned in the previous section, in unidirectional data flow, the UI observes the state. The Android framework offers some great Android Architecture Components that make it easy for you to follow that approach, including the ViewModel and LiveData.

A ViewModel lets you extract the state from the UI and define events that the UI can call to update that state. LiveData allows you to create observable state holders that provide a way for anyone to observe changes to the state.

Unidirectional Data Flow With Architecture Components
Unidirectional Data Flow With Architecture Components

You’ll use the architecture shown in the figure above for your app.

The ViewModel will represent the state, while your composables will represent the UI. In your ViewModels, you’ll use LiveData to hold state.

In your composables, you’ll observe that state and propagate events from child composables to the ViewModel.

Enough theory, it’s time to put this into practice! :]

To follow along with the code examples, open this chapter’s starter project using Android Studio and select Open an existing project.

Next, navigate to 07-managing-state-in-compose/projects and select the starter folder as the project root. Once the project opens, let it build and sync and you’ll be ready to go!

Note that if you skip ahead to the final project, you’ll be able to see the Notes screen and the list of notes in it. :]

Creating the Notes screen

So far, JetNotes has no screens. The only thing you can do with it at the moment is pull out the app drawer and inspect one note, which you use to track your progress. This is about to change. :]

Your next step is to create the Notes screen. To make it easier for you to work on this screen, the database already contains some notes and colors. If you’re interested in the code behind them, check out initDatabase() in RepositoryImpl.kt.

Before you start implementing the ViewModel, you’ll add the entry point for the Notes screen.

In the screens package, create a new Kotlin file named NotesScreen.kt and add the following code to it:

@Composable
fun NotesScreen(viewModel: MainViewModel) {

}

This creates your root composable function for Notes. Notice that NotesScreen() takes MainViewModel as a parameter. You need this because you’ll observe states from the MainViewModel in NotesScreen(). You also need a reference to MainViewModel so you can pass events up to it from the UI.

For this to build successfully, you have to add these necessary imports:

import androidx.compose.runtime.Composable
import com.raywenderlich.android.jetnotes.viewmodel.MainViewModel

Before adding any more code to NotesScreen.kt, you need make this screen the default screen that appears when you open the app. To do this, go to MainActivity.kt and replace the code inside JetNotesTheme() with NotesScreen(viewModel), like this:

JetNotesTheme {
  NotesScreen(viewModel = viewModel)
}

This ensures that Notes opens whenever you run the app. By removing the old code, you temporarily removed the app drawer from the app — but don’t worry, you’ll add it back soon.

Now, add the import for the NotesScreen.

import com.raywenderlich.android.jetnotes.ui.screens.NotesScreen

Finally, build and run the app. You’ll see an empty screen, like this:

Empty Notes Screens
Empty Notes Screens

OK, your canvas is ready!

In the next section, your task will be to connect NoteScreen() with MainViewModel.

Implementing unidirectional data flow

Now that you have an entry point to Notes, you need to implement MainViewModel so it supports unidirectional data flow.

Remember, there are two key concepts in play: states and events.

First, try your hand at breaking down which states are present here. The Notes screen displays a list of notes, which is the state of that screen. Each note contains a few states, which are all encapsulated in NoteModel.

Now, try to expose that state in your MainViewModel.

Open MainViewModel.kt and add the following code to MainViewModel:

val notesNotInTrash: LiveData<List<NoteModel>> by lazy {
  repository.getAllNotesNotInTrash()
}

Repository, which came pre-prepared in the starter project, exposes getAllNotesNotInTrash(), which returns the LiveData of the list of NoteModels. With this, you can easily expose the state of the notes you want to display on the Notes screen.

Now, you need to add few imports:

import androidx.lifecycle.LiveData
import com.raywenderlich.android.jetnotes.domain.model.NoteModel

This was pretty simple. Next, you need to break down which events to pass from NotesScreen to MainViewModel. Looking at the design tells you that there are three events to handle. Users can:

  • Click on a specific note.
  • Click on a floating action button (FAB) to create a new note.
  • Check off a note.

To handle these events, add the following to the bottom of MainViewModel:

fun onCreateNewNoteClick() {
  // TODO - Open SaveNoteScreen
}

fun onNoteClick(note: NoteModel) {
  // TODO - Open SaveNoteScreen in Edit mode
}

fun onNoteCheckedChange(note: NoteModel) {
  viewModelScope.launch(Dispatchers.Default) {
  	repository.insertNote(note)
  }
}

Here, you added three functions that represent three possible events that the view can pass.

  • onCreateNoteClick(): You call this function when the user clicks on a FAB. Right now, its body is empty, but you’ll complete it when you work on the Save Note screen.
  • onNoteClick(): This reacts when the user clicks on any note. To know which note the user selected, it uses NoteModel as a parameter. Once again, its body will remain empty until after you complete the Save Note screen.
  • onNoteCheckedChange(): You call this when the user clicks on a checkbox in any note. It tells the repository to update the specific note in the database.

Finally, to make Android Studio happy, add these imports as well:

import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

Unidirectional Data Flow — Notes Screen
Unidirectional Data Flow — Notes Screen

Great job! You’re now ready to use the MainViewModel in NotesScreen.

Creating the app bar

Before connecting the NotesScreen to the MainViewModel, you need to implement the UI components that make up the Notes screen.

In your Notes screen, you’ll need to add an app bar. But wait a second — check the design and you’ll see that you need that app bar in all your screens. Therefore, it would be handy to implement it as a separate component and reuse it whenever you need it.

In ui.components, create a new Kotlin file named TopAppBar.kt and add the following code to it:

@Composable
fun TopAppBar(
  title: String,
  icon: ImageVector,
  onIconClick: () -> Unit,
) {
  Row(
    modifier = Modifier
      .fillMaxWidth()
      .height(56.dp)
      .background(color = MaterialTheme.colors.primarySurface)
  ) {
    Image(
      imageVector = icon,
      colorFilter = ColorFilter
        .tint(MaterialTheme.colors.onPrimary),
      modifier = Modifier
        .clickable(onClick = onIconClick)
        .padding(16.dp)
        .align(Alignment.CenterVertically)
    )
    Text(
      text = title,
      color = MaterialTheme.colors.onPrimary,
      style = TextStyle(
        fontWeight = FontWeight.Medium,
        fontSize = 20.sp,
        letterSpacing = 0.15.sp
      ),
      modifier = Modifier
        .fillMaxWidth()
        .align(Alignment.CenterVertically)
        .padding(start = 16.dp, end = 16.dp)
    )
  }
}

This code creates an app bar composable that you can reuse on multiple screens. It’s a pretty straightforward composable. You used a Row to align an icon and a text field next to each other. You should be familiar with all the modifiers and specific properties that you use here — you saw them in the previous chapter.

You also exposed a couple of parameters to let you customize the screen. title allows you to change the screen title, while icon lets you set any icon for the app bar.

Finally, since the icon is clickable, you exposed onIconClick so the parent composable can react when the user clicks the icon.

The important concept here is onIconClick. You already saw this concept in Chapter 5, “Combining Composables”. By exposing that specific parameter, you allow the click event to be passed up when the user interacts with this composable.

Pay attention to this concept going forward. You’ll see it a lot in this chapter.

For the code above to work, you need to add some imports as well:

import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.primarySurface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp

Now, it’s time to wrap up the TopAppBar(). Add the following code to the bottom of TopAppBar.kt:

@Preview
@Composable
private fun TopAppBarPreview() {
  JetNotesTheme {
    TopAppBar(
      title = "JetNotes",
      icon = Icons.Filled.List,
      onIconClick = {}
    )
  }
}

Here, you added the preview composable so you can check TopAppBar() in the preview panel. You also took an extra step and used JetNotesTheme as a wrapper to make TopAppBar() use the colors you defined in your theme. However, the preview would work without that, too.

Don’t forget to include these imports as well:

import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.List
import androidx.compose.ui.tooling.preview.Preview
import com.raywenderlich.android.jetnotes.theme.JetNotesTheme

Build the project and check the preview panel. You’ll see something like this:

TopAppBar Composable — Preview
TopAppBar Composable — Preview

Great! You’ve now built an app bar composable that you can reuse in any screen you want. :]

The next thing you’ll do is adapt Note() so you can use it for the Notes screen.

Stateless composables

In MainViewModel, you exposed the list of NoteModels as a state, but your Note() still isn’t ready to render a specific NoteModel.

If you check Note(), which you completed in the previous chapter, you see that its values are all hard-coded. You’ll change that in this section.

Before writing any code, take a moment to think about which state you need to render a note and which events each note should expose.

Unidirectional Data Flow — Note
Unidirectional Data Flow — Note

As you saw before, NotesScreen() needs to be able to pass three events up to MainViewModel and two of those events are a note’s responsibility.

Also, if you want to render the correct information in Note(), you need the data from a NoteModel. NoteModel is a state that a parent composable will pass down to Note().

Now, you’re ready to open Note.kt and add the following parameters to Note():

@Composable
fun Note(
  note: NoteModel,
  onNoteClick: (NoteModel) -> Unit = {},
  onNoteCheckedChange: (NoteModel) -> Unit = {}
) {
	// ...
}

The parameters in the code above represent state and events that will be passed up and down between Note() and its parent composable.

An important principle is hidden in these parameters: state hoisting. If your composable has state, you can use state hoisting to make it stateless. State hoisting is a programming pattern where you move state to the caller of a composable by replacing internal state in a composable with a parameter and events.

For composables, this often means introducing two parameters to the composable:

  • value: T: The current value to display.
  • onValueChange: (T) -> Unit: An event that requests a change to a value, where T is the proposed new value.

The value T represents a generic type, that depends on the data and the UI you’re showing. If you look at the parameters of Note again, you see that you follow the same approach for your state and events. In that case, your T is actually a NoteModel.

By applying state hoisting to a composable, you make it stateless — which means it can’t change any state itself. Stateless composables are easier to test, tend to have fewer bugs and offer more opportunities for reuse.

A stateful composable would be a composable that has a dependency on the final class, which can directly change a specific state. In this example, a stateful composable would be any parent composable that both has a dependency on MainViewModel and can call MainViewModel.onNoteCheckedChange(). Why that specific function? Because it changes the state in the MainViewModel.

Finally, import NoteModel:

import com.raywenderlich.android.jetnotes.domain.model.NoteModel

Now that you understand stateless composables, your next step is to add the logic to render the NoteModel state.

Rendering NoteModel’s state

To render the NoteModel in Note(), you need to replace your current, hard-coded values with the values from NoteModel.

Your first task is to update the code to use a color from NoteModel when you invoke a NoteColor.

NoteColor(
  modifier = Modifier
    .align(Alignment.CenterVertically)
    .padding(start = 16.dp, end = 16.dp),
  color = Color.fromHex(note.color.hex),
  size = 40.dp,
  border = 1.dp
)

This is pretty straightforward. You use the utility function that parses the string color value from NoteModel.color.hex to Color.

Before it will work, you need to add an import to a utility function:

import com.raywenderlich.android.jetnotes.util.fromHex

Now, you’re going to make sure that you show the correct title and content text.

Update Column like this:

Column(
  modifier = Modifier
    .weight(1f)
    .align(Alignment.CenterVertically)
) {
  Text(
    text = note.title,
    ...
  )
  Text(
    text = note.content,
    ...
  )
}

This is also easy to understand. All you did was to replace the hard-coded values you used for the title and the content with NoteModel.title and NoteModel.content.

Now, the last thing to handle regarding state is the checkbox composable. Update the code that handles the checkbox, like this:

if (note.isCheckedOff != null) {
  Checkbox(
    checked = note.isCheckedOff,
    onCheckedChange = {},
    modifier = Modifier
      .padding(16.dp)
      .align(Alignment.CenterVertically)
  )
}

Here, you first check if NoteModel.isCheckedOff is null. If it is, that means that the note isn’t set up for the user to check it off, so it shouldn’t show the checkbox.

If NoteModel.isCheckedOff isn’t null, you invoke Checkbox() and pass that state as a parameter called checked. By doing that, you make sure that the checkbox always has the right state.

Great job! Note() now can successfully render the state that is passed down to it.

Your next step is to add the code that will pass events up.

Passing up Note events

Remember, the first of the two events that a note can pass up to a parent is when a user clicks the note. You’ll handle that first, by updating the Row modifier to allow that:

Row(
  modifier = Modifier
    .padding(8.dp)
    .shadow(1.dp, backgroundShape)
    .fillMaxWidth()
    .preferredHeightIn(min = 64.dp)
    .background(Color.White, backgroundShape)
    .clickable(onClick = { onNoteClick(note) })
) {
  ...
}

Here, you made the Row clickable. As the user clicks on the Row, it triggers the internal onclick() handler from the modifier. That handler then notifies the parent, using onNoteClick(note). Doing so, it passes the NoteModel state of the clicked note up to the parent.

Finally, you need to add one import:

import androidx.compose.foundation.clickable

Well done! Now, you’ll do the same thing for the second event. Update the Checkbox() by adding the following code to its onCheckedChange():

Checkbox(
  checked = note.isCheckedOff,
  onCheckedChange = { isChecked -> // here
    val newNote = note.copy(isCheckedOff = isChecked)
    onNoteCheckedChange(newNote)
  },
  modifier = Modifier
    .padding(16.dp)
    .align(Alignment.CenterVertically)
)

This a bit more complicated, but nothing you can’t handle. :]

Whenever the user clicks the checkbox, it invokes onCheckedChange(), where isChecked contains the new value. You added the code that creates a new NoteModel with the new isCheckedOff state.

After that, you call onNoteCheckedChange(newNote) and pass an event up to the parent with the new NoteModel.

Finally, you shouldn’t forget to update the preview composable to use the new parameters you added to Note:

@Preview
@Composable
private fun NotePreview() {
  Note(note = NoteModel(1, "Note 1", "Content 1", null))
}

Build the project and you’ll see something like this in the preview:

Notes Composable — Preview
Notes Composable — Preview

Unidirectional data flow with stateless composables

Hoisting the state out of Note() has some advantages: It’s now easier to reason about the composable, reuse it in different situations and to test it. Plus, now that you’ve decoupled Note() from how you store the state, if you modify or replace MainViewModel, you don’t have to change how you implement Note().

State hoisting allows you to extend unidirectional data flow to stateless composables. The unidirectional data flow diagram for these composables maintains state going down and events going up as more composables interact with the state.

Unidirectional Data Flow — Note
Unidirectional Data Flow — Note

It’s important to understand that a stateless composable can still interact with state that changes over time by using unidirectional data flow and state hoisting.

Check out the UI update loop for Note():

  • Event: You call onNoteCheckedChange() in response to the user clicking a checkbox in a note.
  • Update State: Note() can’t modify state directly. The caller may choose to modify state(s) in response to onNoteCheckedChange(). Up the chain, a parent composable will call onNoteCheckedChange() on MainViewModel. This, in turn, causes the notesNotInTrash to update and the event updating it will originate from where you called onNoteCheckedChanged().
  • Display State: When notesNotInTrash changes, you call NotesScreen() again with the updated state. That state will propagate down to a specific note. As you saw in previous chapters, calling composables in response to state changes is called recomposition.

You’ve now laid all the groundwork, and you’re ready to let your users see their notes!

Displaying notes in the Notes screen

Now that Note is stateless, you’re ready to display notes in the Notes screen.

Open NotesScreen.kt and update NotesScreen() by adding the following code to the body:

@Composable
fun NotesScreen(viewModel: MainViewModel) {

  val notes: List<NoteModel> by viewModel
    .notesNotInTrash
    .observeAsState(listOf())

  Column {
    TopAppBar(
      title = "JetNotes",
      icon = Icons.Filled.List,
      onIconClick = {}
    )
    LazyColumn {
      items(
        items = notes,
        itemContent = { note ->
          Note(
            note = note,
            onNoteClick = {
              viewModel.onNoteClick(it)
            },
            onNoteCheckedChange = {
              viewModel.onNoteCheckedChange(it)
            }
          )
        }
      )
    }
  }
}

OK, there are a couple of things to unpack here. The most interesting line is the first one, where you access the note’s state from MainViewModel. You can break it apart like this:

  • val notes: List: Declares a variable notes with the type List<NoteModel>.

  • viewModel.notesNotInTrash: Returns an object with the type LiveData<NoteModel>.

  • .observeAsState(listOf()): Converts LiveData<NoteModel> into a State<NoteModel> so that Compose can react to value changes. You pass listOf() as an initial value to avoid possible null results before LiveData initializes. If you didn’t pass the initial value, notes would be List<NoteModel>?, which is nullable.

  • by: This keyword is the property delegate syntax in Kotlin. It automatically unwraps the State<List<NoteModel>> from observeAsState into a regular List<NoteModel>.

Composable functions get subscribed to a State any time you read the value property during its execution. Reading the notes’ value when passing it to LazyColumn subscribed it to State<List<NoteModel>>. Any changes to that state will schedule a recomposition of NotesScreen().

The rest of the code handles emitting UI. You used a Column and put a TopAppBar and a LazyColumn into it.

Notice that in LazyColumn(), you used Note(), which you adapted in the previous section. You pass NoteModel to pass down state. Finally, to allow each Note() to pass up events, you passed calls to viewModel.onNoteClick() and viewModel.onNoteCheckedChange().

Before building, you need to add these imports.

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.List
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import com.raywenderlich.android.jetnotes.domain.model.NoteModel
import com.raywenderlich.android.jetnotes.ui.components.Note
import com.raywenderlich.android.jetnotes.ui.components.TopAppBar

Now, build the project and run the app. You’ll see something like this:

Notes Screen — List of notes
Notes Screen — List of notes

You can see the Notes that were created for you in the starter project. Scroll down to the last two notes and click a checkbox. You’ll notice that state updates whenever you check off a note.

Extracting a stateless composable

Look at NotesScreen() code and you’ll see it has a dependency on the final class, MainViewModel, which directly changes notesNotInTrash’s state. That makes it a stateful composable.

You can also see that the code that changes state is related to the list of notes. Both calls to MainViewModel are inside LazyColumn().

Do you notice something? You could extract that code and make a stateless composable — which is what you’ll do next.

Add the following code to the bottom of NotesScreen.kt:

@Composable
private fun NotesList(
  notes: List<NoteModel>,
  onNoteCheckedChange: (NoteModel) -> Unit,
  onNoteClick: (NoteModel) -> Unit
) {
  LazyColumn {
    items(
      items = notes,
      itemContent = { note ->
        Note(
          note = note,
          onNoteClick = onNoteClick,
          onNoteCheckedChange = onNoteCheckedChange
        )
      }
    )
  }
}

@Preview
@Composable
private fun NotesListPreview() {
  NotesList(
    notes = listOf(
      NoteModel(1, "Note 1", "Content 1", null),
      NoteModel(2, "Note 2", "Content 2", false),
      NoteModel(3, "Note 3", "Content 3", true)
    ),
    onNoteCheckedChange = {},
    onNoteClick = {}
  )
}

Don’t forget to add the Preview import as well:

import androidx.compose.ui.tooling.preview.Preview

Whenever you extract a stateless composable, you should keep two things in mind:

  • The state you’re passing down.
  • The events you’re passing up.

NotesList() has a parameter of type List<NoteModel>, which represents state for NotesList(). You need a list of notes in order to pass down the NoteModels to each Note().

As you learned above, every note needs to pass two events: a click on a note and a click on a checkbox. NoteList exposes the same events because it displays the list of notes. So, when you check the remaining parameters in NotesList, you see that you added onNoteCheckedChange: (NoteModel) -> Unit and onNoteClick: (NoteModel) -> Unit, just as in Note().

Once again, you applied the principle of state hoisting. Check the code inside NotesList() and you’ll notice that this composable can’t change any state. It can only pass state down or pass specific events up. It’s decoupled from how its state, List<NoteModel>, is stored. By applying state hoisting, you made this composable stateless.

Finally, replace LazyColumn inside NotesScreen with NotesList:

Column {
  TopAppBar(
    title = "JetNotes",
    icon = Icons.Filled.List,
    onIconClick = {}
  )
  NotesList( // here
    notes = notes,
    onNoteCheckedChange = { viewModel.onNoteCheckedChange(it) },
    onNoteClick = { viewModel.onNoteClick(it) }
  )
}

This code is pretty straightforward, just be sure to notice that you passed down the same arguments as before. For state, you passed notes and you also passed two calls to MainViewModel.

Now, build and run. In the app, you’ll see the same screen as before, but you’ll see your NotesList in the preview panel.

NotesList composable — Preview
NotesList composable — Preview

Well done! Before you wrap up this chapter, take a moment to review how you’re passing state and events in the Notes screen.

Unidirectional Data Flow — Notes Screen
Unidirectional Data Flow — Notes Screen

This is the main concept behind state management in Compose. Always keep in mind that you pass down state and pass up events. Using state hoisting to create stateless composables makes that really easy.

Wow! You’ve made great progress on the Notes screen. :] You’ll wrap it up in Chapter 8, “Applying Material Design To Compose”.

Great job on completing this chapter! State management is a complex topic, and you’ll see more of it in the following chapters as well.

You can find the final code for this chapter by navigating to 07-managing-state-in-compose/projects/final.

Key points

  • State is any value that can change over time.
  • The UI update loop is made of three key concepts: event, update state and display state.
  • Unidirectional data flow is a design where state flows down and events flow up.
  • You can use the Android Architecture Components, ViewModel and LiveData, to implement unidirectional data flow in Compose.
  • A ViewModel lets you extract state from the UI and define events that the UI can call to update that state.
  • LiveData allows you to create observable state holders.
  • A stateless composable is a composable that cannot change any state itself.
  • State hoisting is a programming pattern where you move state to the caller of a composable by replacing internal state in that composable with a parameter and events.

In the next chapter, you’ll see how you can use material components to easily build UI. You’ll replace some of the composables that currently use basic composables and you’ll build the rest of the app. You’ll also work more with state since there are two more screens to build!

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.