Lifecycle of Composables in Jetpack Compose

Learn about the lifecycle of a composable function and also find out how to use recomposition to build reactive composables. By Lena Stepanova.

See course reviews 3 (1) · 1 Review

Download materials
Save for later
Share

Learn about the lifecycle of a composable function and also find out how to use recomposition to build reactive composables.

Reactive programming is the backbone of Jetpack Compose. It allows you to build UI in a declarative manner. You no longer have to use getters and setters to change the views in response to underlying data. Instead, these changes happen reactively due to recomposition.

In this tutorial, you’ll learn:

  • About the lifecycle of a composable function.
  • Updating a composable from another composable.
  • Observing changes in Logcat.
  • How to use recomposition to build reactive composables.
Note: Recomposition is tightly connected with state in Jetpack Compose. It’s best to go through this tutorial after familiarizing yourself with that concept. Consider starting with Managing State in Compose if you haven’t done so yet.

Getting Started

Download the starter project by clicking Download Materials at the top or bottom of the tutorial. Unzip it and import the starter project into Android Studio.

Exploring the Project Structure

Unlike in a traditional Android project, in the QuizMe app you won’t find a layout resource directory here. The main directory is different as well – The ui directory has a screens package, which contains MainScreen, QuizScreen and ResultScreen. In these files, you’ll declare your UI. And you’ll do it all in Kotlin, with no more XML.

In the same directory, you’ll also find a theme package, which holds everything responsible for your UI items’ appearance.

Navigate to MainActivity.kt. Notice MainActivity doesn’t extend AppCompatActivity as usual but a ComponentActivity. This is possible thanks to changes in the app build.gradle file. When you create a new project, you can start with an Empty Compose Activity, and Android Studio will add necessary dependencies for you.

But if you’re adding Jetpack Compose to an existing project, you first need to configure it in your app-level build.gradle file to comply with Jetpack Compose and then add the relevant dependencies.

Identifying the Problem

Now that you’re more familiar with the project, build and run it. You’ll see a quiz form.

Quiz screen input with immutable string

Are you ready for a challenge? Try answering some of the questions.

Unfortunately, nothing appears if you start typing in the input fields. This is because your UI isn’t responsive yet. :[

Open QuizScreen.kt and take a look at QuizInput():

@Composable
fun QuizInput(question: String) {
    Log.d(MAIN, "QuizInput $question")
    val input = ""
    TextField(
        value = input,
        onValueChange = { },
        modifier = Modifier
            .fillMaxWidth()
            .padding(top = 16.dp),
        placeholder = {
            Text(text = question)
        }
    )
}

This composable function is responsible for updating the text field for answers. It holds an input variable, which is just an immutable String. Currently, there’s no state that TextField() could react to in order to update its value. The onValueChange callback is empty as well.

It’s time to change this so your answers appear in the text field. But first, you need to learn about the lifecycle of a composable.

Lifecycle of a Composable

As part of the Android ecosystem, every composable has its own lifecycle. Luckily, it’s more simple compared to other components. Every composable goes through three stages of life:

  • Entering the composition
  • Recomposition
  • Leaving the composition

Lifecycle of Composable

A composable can recompose as many times as needed to render the latest UI.

Triggering Recomposition

In the starter project, you declare the composition within setContent() in MainActivity.

After you start QuizMe, your composables enter the composition declared in MainScreen() which then calls QuizScreen() with all its composables. But nothing gets recomposed afterward. QuizInput() never enters the next stage of lifecycle because your UI remains static.

You need to give instructions to your composable functions to recompose and display the updated UI.

Note: If you’re wondering why there are no setters and getters in composable functions, you can learn more about it in Jetpack Compose by Tutorials — Updated for the Compose Beta!

State plays an important role in declarative UIs. Recomposition happens when any of the states involved in the initial composition changes. Go ahead and change the immutable String variable to a MutableState variable, making the QuizInput() stateful.

First, locate the input variable and, instead of an empty text, use remember() to create a mutable state:

var input by remember { mutableStateOf("") }

Now the input is a state variable that holds a String value. You use this value in TextField(). Here, remember() helps the composable keep the latest state of the input between recompositions.

Next, three lines below, update the onValueChange callback:

onValueChange = { value -> input = value  }

When the value of the text field changes, onValueChange() gets triggered. It sets the value of the mutable state to the new input string and, as a result, Jetpack Compose reacts to this state change and recomposes TextField().

Build and run the app again. Try entering your answers to the questions now. When you enter a new letter in the text field, the recomposition happens, and you can see the latest input.

Quiz input screen with input state

Well done!

But what if you want to trigger the recomposition of another composable in response to changes in QuizInput()? For example, adding another TextField() to your initial screen layout when the user selects the checkbox. Find the answer in the next paragraph.

Defining the Source of Recomposition

If you want to trigger the recomposition of one composable to another, use state hoisting and move the responsibility to the composable in control.

For example, look at CheckBox() in QuizScreen.kt:

@Composable
fun CheckBox(
   // 1
   isChecked: Boolean,
   onChange: (Boolean) -> Unit
) {
   . . .
   Checkbox( 
       // 2
       checked = isChecked, 
       onCheckedChange = onChange
   )
   . . .
}

Here’s how it works:

  1. It receives a Boolean checked state and an onChange lambda as parameters. This is useful because you can reuse the composable multiple times and there won’t be any side-effects. Also, you added flexibility to the composable function because it can receive the state from anywhere now.
  2. You assigned the value and lambda to the Checkbox component. Now, if the user changes the checked state, the component reacts with the onChange lambda, lifting the current state of isChecked to QuizScreen().

Look at the following lines in QuizScreen():

​​var checked by remember { mutableStateOf(false) }
val onCheckedChange: (Boolean) -> Unit = { value -> checked = value }

These mean QuizScreen() is the actual holder of the checked state value. It’s in control of the value and can change that value when it gets an onCheckedChange callback.

As a result, you can use the checked state from QuizScreen() to trigger recomposition of other composables.

Next, you want to disable submitting the form if the box isn’t checked at least once. Change the signature of SubmitButton() like this:

@Composable
fun SubmitButton(isChecked: Boolean, text: String, onClick: () -> Unit)

Here you provide the state value to a composable function for the submit button.

Then, add a parameter to ExtendedFloatingActionButton() inside SubmitButton():

backgroundColor = if (isChecked) MaterialTheme.colors.secondary else Color.Gray

With this line, you handle changing the background color of the submit button depending on the checked value.

Also, in QuizScreen(), pass the checked state to SubmitButton():

SubmitButton(checked, stringResource(id = R.string.try_me)) {  }

Build and run the app. Try using the checkbox.

Trigger recomposition from checkbox

The button changes its color when the user selects or unselects the checkbox. Wow! You’re triggering the recomposition of the button in response to checkbox state changes.

Observing Logs and Conditions

Open Logcat, select com.yourcompany.android.quizme and observe the recomposition in the log output:

D/MainLog: Checked state true
D/MainLog: Button recomposed
D/MainLog: Checked state false
D/MainLog: Button recomposed

As you can see, the recomposition happened as many times as you clicked on the checkbox.

Now add logic for disabling the button if the checkbox isn’t checked. In QuizScreen(), add a condition for SubmitButton():

if (checked) {
   SubmitButton(checked, stringResource(id = R.string.try_me)) { }
}

Now Compose won’t do the button recomposition if the box isn’t checked.

Build and run the app. Tap the checkbox a few times. Notice the button is visible only when you select the checkbox.

Recomposed button

The log output is different as well:

D/MainLog: Checked state true
D/MainLog: Button recomposed
D/MainLog: Checked state false
D/MainLog: Checked state true
D/MainLog: Button recomposed
D/MainLog: Checked state false

Recomposition follows conditional rules, just like every other piece of code in Kotlin. When the checked state is set to false, there’s no need for Jetpack Compose to recompose the SubmitButton().

Skipping Recomposition

Jetpack Compose is intelligent and can choose to skip recomposition when it’s not needed.

Inside the if clause, above SubmitButton(), assign a new value to the questions variable:

questions = listOf(
  "What's the best programming language?",
  "What's the best OS?",
  "The answer to Life, The Universe and Everything?"
)

Here you added one more question to the list. This will result in adding a new input field dynamically the first time the user selects the checkbox.

Build and run the app. Select the checkbox and you’ll see a new text field appears:

Selecting checkbox adds new text field

Check the log output. You’ll see the following:

D/MainLog: Checked state true
D/MainLog: Button recomposed
D/MainLog: QuizInput The answer to Life, The Universe and Everything?

Notice that the logs contain only the new QuizInput field name here. Jetpack Compose recognized that the first two items in the list hadn’t changed, so it didn’t have to recompose them.

When you select the checkbox, it sends an event to the QuizScreen(), which sets the checked state to the new value. In its turn, Jetpack Compose knows which composables to recompose. In the log output, you see that the CheckBox() gets recomposed each time the user selects or unselects it. But the button gets recomposed only when the checked state is true. InputFields() isn’t affected by the user interaction with the checkbox at all.

This happens because Jetpack Compose skips recomposition where possible to stay optimized. Another example of this is when a conditional statement defines when not to show a composable as with SubmitButton() or when the state doesn’t affect a composable or the state hasn’t changed, as with InputFields().

Smart Recomposition

How does Jetpack Compose know whether the state has changed? It simply uses equals() to compare the new and the old values of the mutable state. Therefore, any immutable type won’t trigger recomposition.

Do you remember trying to use InputFields() with the immutable String value at the beginning of the tutorial? Jetpack Compose didn’t want to recompose anything because the value couldn’t change.

Any immutable type is stable and doesn’t trigger recomposition. That’s why you use remember() combined with mutableStateOf(). Jetpack Compose observes changes of the mutable state so it knows when to start recomposition and which composable functions it should recompose.

Modify the previous code block like this:

questions = listOf(
   "The answer to Life, The Universe and Everything?",
   "What's the best programming language?",
   "What's the best OS?"
) 

Here you shuffled questions in the list.

Build and run the app again. Select the checkbox.

Recomposed three questions

At first sight, it still looks the same. But in the log output, you’ll see Compose recomposed all three inputs:

D/MainLog: Checked state true
D/MainLog: Button recomposed
D/MainLog: QuizInput The answer to Life, The Universe and Everything?
D/MainLog: QuizInput What's the best programming language?
D/MainLog: QuizInput What's the best OS?

Once Jetpack Compose started to compare the new list with the old, it noticed that the first item didn’t equal the first item of the old list. In that case, it couldn’t reuse the old QuizInputs() and had to recompose them all. But how to be sure Jetpack Compose will use old composables if there are any?

Modify QuizInputFields() by wrapping QuizInput() into key():

key(question){
    QuizInput(question = question)
}

To ensure Jetpack Compose reuses old composables, you use key(). The question value you provided is identifier for a certain instance of QuizInput.

Build and run the app again. Select the checkbox. The UI remains the same!

Recomposed three questions

Recheck the log output. This time, it looks like when you added the new question to the bottom of the list – only the new InputField() was recomposed:

D/MainLog: Checked state true
D/MainLog: Button recomposed
D/MainLog: QuizInput The answer to Life, The Universe and Everything?

Although the list order changed, Jetpack Compose reused two initial question fields because it could identify them this time. This is an important concept to remember when you need to render longer lists or when the list order is essential.

Interacting With ViewModels

What you want to do now is to verify the user inputs and let them know if they’re on the right track. Check out the repository and business folders in your project.

To produce questions and validate the answers, you’ll mock the backend in QuestionsRepository. You’ll use QuizViewModel to connect the UI with your data source.

Passing Data From a ViewModel to a Composable

Add these two functions to QuizViewModel to fetch questions:

fun fetchQuestions(): List<String> {
   return repository.getQuestions()
}

fun fetchExtendedQuestions(): List<String>  { 
   return repository.getExtendedQuestions()
}

These will call the functions from the repository to fetch a basic and an extended list of questions, which you introduced earlier.

In QuizScreen.kt, find QuizScreen() and replace the hardcoded listOf() questions with the function call from quizViewModel:

var questions by remember {
   mutableStateOf(quizViewModel.fetchQuestions())
}

Also, above SubmitButton(), modify the extended list by replacing listOf() with this:

questions = quizViewModel.fetchExtendedQuestions()

Build and run the app. You shouldn’t notice any changes because recomposition still works as before. Only the data source has changed.

Recomposed three questions

Congrats! You just connected QuizViewModel with your composable function.

Reacting With LiveData and SharedFlow

When communicating with your ViewModel from Jetpack Compose UI, you have several options for reacting to changes.

Navigate to QuizViewModel.kt and look at the following variables:

// 1
private val _state = MutableLiveData<ScreenState>(ScreenState.Quiz)
val state: LiveData<ScreenState> = _state

// 2
private val _event = MutableSharedFlow<Event>()
val event: SharedFlow<Event> = _event

Here’s the explanation:

  1. state holds the state of the screen and signals to Jetpack Compose when to switch from QuizScreen() to ResultScreen().
  2. event helps you interact with the user by showing dialogs or loading indicators.

These variables hold a ScreenState or an Event instance, which are sealed classes at the bottom of QuizViewModel.

Go back to QuizScreen.kt. In QuizScreen(), add a map of answers above the onAnswerChanged value:

val answers = remember { mutableMapOf<String, String>() }

Using remember(), you ensure the previous answers aren’t lost when QuizScreen() recomposes.

Then, complete the onAnswerChanged lambda:

answers[question] = answer

You use the question and answer values to set the data in the answers map.

Next, in QuizInput(), add a new parameter to the signature:

fun QuizInput(question: String, onAnswerChanged: (String, String) -> Unit) 

Then, invoke onAnswerChanged once value changes. Modify code in the onValueChange lambda like this:

run {
    input = value
    onAnswerChanged(question, input)
} 

When the user enters a new letter, the value of the input state changes and the new answer gets saved in the answers map.

To fix errors you made with the previous code, look for QuizInputFields() and provide the onAnswerChanged callback to QuizInput():

QuizInput(question = question, onAnswerChanged = onAnswerChanged)

Finally, you need to verify the answers once the user submits the quiz. In QuizScreen(), complete the callback in SubmitButton() with this:

quizViewModel.verifyAnswers(answers)

Now that you’ve connected your UI with QuizViewModel and QuestionsRepository, all that’s left to do is declare how your UI should react to changes.

Go to QuizViewModel.kt and check out verifyAnswers():

fun verifyAnswers(answers: MutableMap<String, String>) {
    viewModelScope.launch {

      // 1
      _event.emit(Event.Loading)
      delay(1000)
      
      // 2
      val result = repository.verifyAnswers(answers)
      when (result) {
        
        // 3
        is Event.Error -> _event.emit(result)
        else -> _state.value = ScreenState.Success
      }
    }
  }

This function has several important points:

  1. When verifyAnswers() is called, the SharedFlow emits a Loading event.
  2. After 1 second, verifyAnswers() from repository verifies the answers.
  3. If the answers aren’t correct, event emits an Error. Otherwise, you use LiveData to set state to ScreenState.Success.

As you can see, event and state should be transmitted to the composables from ViewModel via LiveData or SharedFlow you defined before.

All that’s left now is to declare how MainScreen() will react to changes in state and event.

Navigate to MainScreen.kt and replace the code inside MainScreen() as follows:

  // 1
  val state by quizViewModel.state.observeAsState()
  val event by quizViewModel.event.collectAsState(null)

  // 2
  when (state) {
    is ScreenState.Quiz -> {
      QuizScreen(
        contentPadding = contentPadding,
        quizViewModel = quizViewModel
      )
      // 3
      when (val e = event) {
        is Event.Error -> {
          ErrorDialog(message = e.message)
        }
        is Event.Loading -> {
          LoadingIndicator()
        }
        else -> {}
      }
    }
    is ScreenState.Success -> {
      ResultScreen(
        contentPadding = contentPadding,
        quizViewModel = quizViewModel
      )
    }
    else -> {}
  }

Add the imports where necessary. Here’s a breakdown of the code:

  1. It’s up to you which observable class to use for interacting with the ViewModel. But keep in mind the differences: You use observeAsState() to observe the LiveData and collectAsState() to observe the SharedFlow. You also need to set the initial state for the SharedFlow. That’s why you pass null to collectAsState().
  2. Here, you instruct Jetpack Compose to recompose the UI depending on the state value.
  3. In this use case, you need to handle events only in QuizScreen() and react accordingly to whether it emits an Error or a Loading event. Here, you assign event to a local variable so you can use the smart cast to Event and access its message property.

Build and run the app. Tap Try me!. You’ll see an error dialog:

Error dialog

Try answering the questions. If you do it incorrectly, you see an error dialog with an appropriate message. If you manage to answer all the questions, you’re redirected to the result screen.

Result Screen

Great, you’re almost there!

Open ResultScreen.kt and add another button to ResultScreen() right below Congrats():

SubmitButton (true, stringResource(id = R.string.start_again)) {
  quizViewModel.startAgain()
}

This code block allows you to start the quiz again.

Build and run the app. When you answer all questions correctly you’ll see the result screen. There you have the button to start the quiz again.

Start again button

Challenge: Adding LiveData for Questions

Now that you know how to observe variables from the ViewModel, you can make the app even prettier by adding another observable variable to QuizViewModel:

private val _questions = MutableLiveData<List<String>>()
val questions: LiveData<List<String>> = _questions

Before proceeding with the article, adjust the rest of the project and, when you’re ready, compare it with the suggested solution below.

[spoiler title=”Solution”]

Modify fetchQuestions() and fetchExtendedQuestions() in QuizViewModel:

fun fetchQuestions() {
   _questions.value =  repository.getQuestions()
}
 
fun fetchExtendedQuestions() {
   _questions.value = repository.getExtendedQuestions()
}

Notice that the return types and the body of these functions have changed. You don’t return the list of questions anymore. Now you use LiveData for storing the questions.

Now, add an init block to QuizViewModel:

init {
   fetchQuestions()
}

Here you call fetchQuestions() while the QuizViewModel is being initialized. This way, you’ll have your LiveData‘s prepared once the app starts.

Finally, open QuizScreen.kt and find QuizScreen(). Change the questions value to look like this:

val questions by quizViewModel.questions.observeAsState()

With this line you observe questions as a State.

A few lines below, above the SubmitButton callsite, wrap the QuizInputFields() call with the let block:

questions?.let { QuizInputFields(it, onAnswerChanged) }

This is necessary because observeAsState() returns a nullable value.

In the line below, remove assigning value to questions:

quizViewModel.fetchExtendedQuestions()

You don’t need to assign the new value to questions because there fetchExtendedQuestions() doesn’t have a return type anymore.

Build and run the app.

Recomposed three questions

Everything still works fine. Great job!
[/spoiler]

Observing Composable Lifecycle

You’re almost done. Pass the quiz and tap Start again to restart the quiz again. Oops, all three questions are still there.

But how do you show the initial list of just two questions again?

Of course, you could simply refresh the questions in startAgain(). But, this time you’ll try another approach.

In QuizScreen.kt, add this to the bottom of QuizScreen():

DisposableEffect(quizViewModel) {
   quizViewModel.onAppear()
   onDispose { quizViewModel.onDisappear() }
}

This code retrieves the initial questions at the end of the QuizScreen() lifecycle before it disappears from the screen.

Next, in QuizViewModel.kt, add the following line at the end of onDisappear():

fetchQuestions()

This code creates a side-effect. DisposableEffect() instructs QuizViewModel to call onAppear() when QuizScreen() enters the composition and call onDisappear() when it leaves the composition. This way, you can bind your ViewModel to the composable’s lifecycle so that when you move from the quiz screen to the result screen, the questions list in QuizViewModel is refreshed.

Notice that you’re using quizViewModel as a key to DisposableEffect() here, which means it will also be triggered if quizViewModel changes.

Many other side-effects can help you fine-tune the recomposition of various composables. But keep in mind you must use side-effects with caution because they will be apply even if the recomposition is canceled or restarted due to new state changes. This can lead to inconsistent UI if any of your composables depend on the side-effects.

Build and run the app. If you pass the quiz and start again, the screen looks as fresh as before.

Initial screen

Check out the log output. As you can see, the list of questions is recomposed before the quiz screen appears:

D/MainLog: QuizScreen disappears
D/MainLog: QuizInput What's the best programming language?
D/MainLog: QuizInput What's the best OS?
D/MainLog: Checked state false
D/MainLog: QuizScreen appears

Where to Go From Here?

Congratulations, you completed the tutorial! Download the final project by clicking Download Materials at the top or bottom of the tutorial.

In a more complicated app, you’ll use navigation to go from one screen to another instead of just switching the composables. Therefore, you might want to check out this article on Compose Destinations.

You also can continue learning about Jetpack Compose from the Jetpack Compose by Tutorials book, deepen your knowledge on Managing State in Compose or find out about other side-effects than DisposableEffect.

To learn more about state hoisting, check out the official documentation.

Keep it up on your Jetpack Compose journey. You still have much to explore. :]

If you have any questions or comments, join the forum discussion below.