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

11. Reacting to Compose Lifecycle
Written by Tino Balint

In previous chapters, you focused on building the JetReddit app by adding advanced layouts and complex UI.

In this chapter, you’ll learn how to react to the lifecycle of composable functions. This approach will allow you to execute your code at specific moments while your composable is active.

Jetpack Compose offers a list of events that can trigger at specific points in the the lifecycle, called effects. Throughout this chapter, you’ll learn about the different kinds of effects and how to use them to implement your logic.

Events in Compose

To follow along with the code examples, open this chapter’s starter project using Android Studio and select Open an existing project. Navigate to 11-reacting-to-compose-lifecycle/projects and select the starter folder as the project root. Once the project opens, let it build and sync and you’re ready to go!

You might already be familiar with the project hierarchy from the previous chapter, but in case you aren’t, look at the following image:

Project Hierarchy
Project Hierarchy

In this chapter, you’ll only work with two of these packages: screens, to implement a new screen, and routing, to add a new routing option. The rest of the packages are already prepared to handle navigation, fetching data from the database, dependency injection and theme switching for you.

Once you’re familiar with the file organization, build and run the app. You’ll see:

Home Screen
Home Screen

This is a fully implemented home screen. When you browse the app, you’ll notice that two screens are pre-built and implemented for you: My Profile, in the app drawer, and New Post, the third option in the bottom navigation.

In this chapter, you’ll implement the option to choose a community inside the New Post screen:

New Post Screen
New Post Screen

However, before you start building this new screen, you need to learn more about effects in Compose.

Basic effects in Compose

In Compose, an effect is an event that triggers at a specific time during the composable lifecycle. There are three basic events:

  • onActive: Triggers a callback only once, upon the first composition.
  • onCommit: Triggers a callback every time a new composition commits, including recompositions.
  • onDispose: Triggers a callback when the composable is no longer part of the composition. The most common case is when a user changes screens or certain components are removed due to state changes.

To use one of these effects in your code, you only need to write its name and add your desired code inside the curly brackets.

Open HomeScreen.kt, as you’ll add the callbacks there. Now, try out the effects by adding the following lines at the bottom of HomeScreen():

onActive { Log.d("HomeScreen", "onActive") }
onCommit { Log.d("HomeScreen", "onCommit") }
onDispose { Log.d("HomeScreen", "onDispose") }

These are just simple logs to help you understand when each effect triggers. Make sure to add the missing imports by using quick actions.

Build and run the app, then switch to the Logcat section inside Android Studio and look for the events with the HomeScreen tag.

Upon opening HomeScreen, onActive is logged, followed by two onCommit logs. That’s because you call onActive only once, while onCommit is called every time there’s a new composition.

The first onCommit log happens because, initially, an empty list is passed and the screen is empty. Reading the data from the database triggers a new composition, which logs a second onCommit.

Next, switch to another screen by clicking a different item on the bottom navigation bar. You’ll see an onDispose log because HomeScreen is no longer part of the composition.

To see how effects trigger events on a screen with many recompositions, open AddScreen.kt and add the same logs inside AddScreen(). Every time you write text inside TextField(), it triggers the recomposition and Jetpack Compose calls onCommit().

When you’re ready to move on to the next section, delete the logs you just added. You won’t need them in the app.

Implementing the community chooser

Next, you’ll implement a community chooser like the one the original Reddit app uses. Look at the following image for reference:

Reddit Community Chooser
Reddit Community Chooser

The community chooser above contains a toolbar, a search input field and a list of communities. To fetch the community list, you’ll use a ViewModel that contains pre-built methods.

Open ChooseCommunityScreen.kt and look at the code. There are three composables: ChooseCommunityScreen() for the whole screen, SearchedCommunities() for the community list and ChooseCommunityTopBar(), for the pre-built top navigation bar.

Creating a list of communities

As you learned in the previous chapters, you’ll build the smaller components first, starting with SearchedCommunities(). Start by changing SearchedCommunities() code to the following:

@Composable
fun SearchedCommunities(
  communities: List<String>,
  viewModel: MainViewModel?,
  modifier: Modifier = Modifier
) {
  communities.forEach {
    Community(
      text = it,
      modifier = modifier,
      onCommunityClicked = {
        viewModel?.selectedCommunity?.postValue(it)
        JetRedditRouter.goBack()
      }
    )
  }
}

In the composable parameters, you see a list of strings that represent community names, the MainViewModel to update the data and a default Modifier.

First, you iterate over the communities list and create a Community() for each element. You already made Community() in the previous chapter, so this is a perfect opportunity to reuse it.

Next, for each of the community elements, you pass its name and a modifier, then set the onCommunityClicked action. When the user clicks any of the communities, you notify the other composables about the selected value using selectedCommunity, which is stored inside the viewModel.

Finally, you close the screen after the user selects the community by calling goBack() on the JetRedditRouter.

To see the changes, add the preview code at the bottom of ChooseCommunityScreen.kt:

@Preview
@Composable
fun SearchedCommunitiesPreview() {
  Column {
    SearchedCommunities(defaultCommunities, null, Modifier)
  }
}

Build the app and look at the preview section. You see a list of communities with three elements:

Searched Communities Preview
Searched Communities Preview

Making the community list searchable

The next step is to add a TextField() to search the communities according to user input. Replace ChooseCommunityScreen() with the code below:

@Composable
fun ChooseCommunityScreen(viewModel: MainViewModel, modifier: Modifier = Modifier) {
  val scope = rememberCoroutineScope()
  val communities: List<String> by viewModel.subreddits.observeAsState(emptyList())
  var searchedText by remember { mutableStateOf("") }
  var currentJob by remember { mutableStateOf<Job?>(null) }

  onActive {
    viewModel.searchCommunities(searchedText)
  }

  Column {
    ChooseCommunityTopBar()
    TextField(
      value = searchedText,
      onValueChange = {
        searchedText = it
        currentJob?.cancel()
        currentJob = scope.async {
          delay(SEARCH_DELAY_MILLIS)
          viewModel.searchCommunities(searchedText)
        }
      },
      leadingIcon = { Icon(Icons.Default.Search) },
      label = { Text(stringResource(R.string.search)) },
      modifier = modifier
        .fillMaxWidth()
        .padding(horizontal = 8.dp),
      backgroundColor = MaterialTheme.colors.surface,
      activeColor = MaterialTheme.colors.onSurface
    )
    SearchedCommunities(communities, viewModel, modifier)
  }
}

Here, you first created a coroutineScope by calling rememberCoroutineScope(). rememberCoroutineScope() is a SuspendingEffect, which is a type of complex effect in Compose. It creates a CoroutineScope, which is bound to the composition. CoroutineScope is only created once, and it stays the same even after recomposition. Any Job belonging to this scope will be canceled when the scope leaves the composition.

Then, you create three states: one for the list of communities, which is observed from the database. The second for searchedText, which updates based on user input. The third stores your search Job.

Next, you called onActive() to search the communities when the composition is first composed. Searching for an empty string will return all communities from the database.

Finally, you added a Column() with three composables: the pre-built ChooseCommunityTopBar(), TextField() to capture the user input and SearchedCommunities() to display the list of communities.

With each value change inside TextField(), this code cancels the previous Job and starts a new one inside the scope you already created.

Inside the code block of the coroutine, you added delay() with a 300-millisecond delay. This prevents a new community search from starting each time the user types a new character, unless more than 300 milliseconds pass between keystrokes. Updating searchedText cancels the previous Job and a new one launches with a new delay.

Build and run, then open the New Post screen by selecting the third option in the bottom navigation.

Click the Choose a community button to open the screen you just implemented:

Community Chooser
Community Chooser

You see a list of communities and a search input field that you can use to filter the current list. If you type fast, the list won’t update until you wait for more than 300 milliseconds.

Currently, you’re fetching data from a local database, but when searches use a remote API, this implementation saves your network data and reduces the number of requests a server might receive.

If you want to go back without selecting a community, you can click the Close icon from the top app bar. But what happens when you click the built-in back button on your device? The app closes instead of navigating to the previous screen.

Next, you’ll use effects to implement the back navigation.

Implementing the back button handler

In previous sections, you used built-in back button handlers. This time, you’ll use effects to build your own.

To achieve back button handling in Compose, you need to use dispatchers, which allow you to register appropriate callbacks.

Open BackButtonHandler.kt inside routing and replace BackButtonHandler() with the following:

@Composable
fun BackButtonHandler(
  enabled: Boolean = true,
  onBackPressed: () -> Unit
) {
  val dispatcher = BackPressedDispatcher.current ?: return
  val backCallback = remember {
    object : OnBackPressedCallback(enabled) {
      override fun handleOnBackPressed() {
        onBackPressed.invoke()
      }
    }
  }
  DisposableEffect(dispatcher) {
    dispatcher.addCallback(backCallback)
    onDispose {
      backCallback.remove()
    }
  }
}

BackButtonHandler() takes two parameters:

  • enabled: Determines if back pressing is enabled.
  • onBackPressed(): Invokes an action when the user presses a button.

First, you created a dispatcher property using AmbientBackPressedDispatcher. BackPressedDispatcher is a pre-built static Ambient of type OnBackPressedDispatcher that allows you to add and remove callbacks for system back button clicks.

Next, you made a backCallback by overriding OnBackPressedCallback. This callback receives a parameter that indicates if it’s enabled, then overrides handleOnBackPressed(), which triggers when the user presses the back button. Note that the callback consumes the composable parameters described earlier to set the enabled state and invoke the desired action.

Finally, you added DisposableEffect(), passing dispatcher as a parameter. You added a callback to dispatcher, then called onDispose() to remove that callback.

DisposableEffect is a side effect of the composition that accepts a parameter called subject. Every time subject changes, you need to dispose the effect and call it again. The effect is also disposed when you leave the composition. You handle this by calling onDispose() where you removed the dispatcher callback. This prevents leaks.

In your case, the effect is disposed and re-launched every time dispatcher changes, which is possible because dispatcher depends on the lifecycle of the app.

Adding an action to the back button

The next step is to build BackButtonAction() and provide the previous Ambient. Replace BackButtonAction() with the following:

@Composable
fun BackButtonAction(onBackPressed: () -> Unit) {
  Providers(
    BackPressedDispatcher provides (
        AmbientLifecycleOwner.current as ComponentActivity
        ).onBackPressedDispatcher
  ) {
    BackButtonHandler {
      onBackPressed.invoke()
    }
  }
}

BackButtonAction() takes one parameter, onBackPressed(), which is the action that needs to occur when the user presses the Back button.

You provided BackPressedDispatcher by passing AmbientLifecycleOwner and calling current on it, which returns the current value of the lifecycle owner. You need to cast this value as ComponentActivity to retrieve the back press dispatcher for the current Activity by calling onBackPressedDispatcher.

Next, you used the previous BackButtonHandler() and invoked onBackPressed() as your action. You didn’t pass the enabled parameter, which enables callbacks by default.

Calling the back button’s action

Now that you’ve implemented BackButtonAction(), the only thing left to do is to call it from inside ChooseCommunityScreen().

To do this, add the following code at the bottom of ChooseCommunityScreen():

BackButtonAction {
  JetRedditRouter.goBack()
}

Here, you just added a BackButtonAction() and invoked goBack() on the router to go to the previous screen.

Build and run, then open the Choose a community screen. There are no new UI changes in the app, but you can now click either the close icon or the system back button to go to the previous screen.

At this stage, you’ve learned about two types of complex effects in Compose. Next, you’ll cover even more complex effects.

Complex effects in Compose

To understand the topic of complex effects more clearly, you first need to learn how side effects work in Compose.

Side effects are operations that change the values of anything outside the scope of the function. An example of this is when a mutable object is passed to a function and changes some of that function’s properties. Such changes can affect other parts of the code that use the same object, so you need to be careful when applying them.

The biggest problem with side effects is that you don’t have control over when they actually occur. This is problematic in composables because the code inside them executes every time a recomposition takes place. Effects can help you by giving you control over when the code executes.

Here are more details about specific complex effects.

SideEffect

SideEffect() ensures that your event only executes when a composition is successful. If the composition fails, the event is discarded. In addition, only use it when you don’t need to dispose the event, but want it to run with every recomposition.

Take a look at the snippet below:

@Composable
fun MainScreen(router: Router) {
  val drawerState = rememberDrawerState(DrawerValue.Closed)

  SideEffect {
    router.isRoutingEnabled = drawerState.Closed
  }
}

In this snippet, SideEffect() changes the state of the router. You disable the routing in the app when the drawer is closed: otherwise, you enable it. In this case, router is a singleton and you don’t want to dispose it because other screens are using it for navigation.

The next effect, LaunchedEffect(), is similar to rememberCoroutineScope(), which you used earlier.

LaunchedEffect

LaunchedEffect launches a coroutine into the composition’s CoroutineScope. Just like rememberCoroutineScope(), its coroutine is canceled when LaunchedEffect leaves the composition and will relaunch on recomposition.

See the example below to get a deeper insight:

@Composable
fun SpeakerList(searchText: String) {
  var communities by remember { mutableStateOf<List<String>>(emptyList()) }
  LaunchedEffect(searchText) { 
    communities = viewModel.searchCommunities(searchText)
  }

  Communities(communities)
}

This snippet is similar to what you did when you implemented the search feature in ChooseCommunityScreen().

When you implemented ChooseCommunityScreen, searchText was a mutable state depending on the user input. This time, searchText is a function parameter and isn’t saved as a mutable state. According to the Google guidelines, you should follow this approach to prevent performance issues.

LaunchedEffect initiates the first time it enters the composition and every time the parameter changes. It cancels all running Jobs during the parameter change or upon leaving the composition.

The next effect, Invalidate(), might be familiar to you because it’s used in custom views.

Invalidate

invalidate() is an Effect that manually invalidates the composition, which causes recomposition.

The rare case where you might want to use it is when you are using a stateless property in your composable, as in the example below:

@Composable
fun MyComposable(viewModel: ViewModel) {
    val name = viewModel.getName { invalidate() }
    Text(text = "Hello: $name")
}

In this example, Text displays a name that isn’t a State, so you need to trigger recomposition yourself. Although it’s possible to use this effect, it’s better to use State, which triggers recomposition for you.

Key points

  • onActive() triggers the event only once, upon the first composition.
  • onCommit() triggers an event every time composition occurs.
  • onDispose() triggers an event when your composable leaves the composition.
  • Use rememberCoroutineScope() when you are using coroutines and need to cancel and relaunch the coroutine after an event.
  • Use LaunchedEffect() when you are using coroutines and need to cancel and relaunch the coroutine every time your parameter changes and it isn’t stored in a mutable state.
  • DisposableEffect() is useful when you aren’t using coroutines and need to dispose and relaunch the event every time your parameter changes.
  • SideEffect() triggers an event only when the composition is successful and you don’t need to dispose the subject.
  • invalidate() manually triggers recomposition.

Where to go from here?

Congratulations! Now, you know how to react to Compose lifecycle, which is one of the most complex parts of Jetpack Compose. At this point, you’ve seen an overview of how to solve some of the most complex and important problems you encounter while working with Compose.

However, with Jetpack Compose Alpha 10, lifecycle has changed quite a bit and you might not be sure which effects to use and when. Because onActive(), onCommit() and onDispose() have been deprecated, you can use DisposableEffect() whenever you want to run an effect that you want to dispose after it’s finished computing.

That way, you use DisposableEffect(constant), to build an effect that runs every time the composable element enters composition and disposes every time it leaves the UI tree. This is similar to the onActive() and onCommit() combination you used in this chapter.

In the next chapter, you’ll learn how to use animations to make your UI more beautiful. Animations are fun — and finally easy to do! — so read on and enjoy.

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.