Chapters

Hide chapters

Jetpack Compose by Tutorials

First Edition · Android 11 · Kotlin 1.4 · Android Studio Canary - Arctic Fox Release

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

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 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 the ChooseCommunityScreen.kt file 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) }
  val activeColor = MaterialTheme.colors.onSurface

  LaunchedEffect(Unit) {
    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, contentDescription = stringResource(id = R.string.search))
      },
      label = { Text(stringResource(R.string.search)) },
      modifier = modifier
        .fillMaxWidth()
        .padding(horizontal = 8.dp),
      colors = TextFieldDefaults.outlinedTextFieldColors(
        focusedBorderColor = activeColor,
        focusedLabelColor = activeColor,
        cursorColor = activeColor,
        backgroundColor = MaterialTheme.colors.surface
      )
    )
    SearchedCommunities(communities, viewModel, modifier)
  }
}

Here, you first created a coroutineScope by calling rememberCoroutineScope(). rememberCoroutineScope() is a SuspendingEffect, which is a type of an 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 LaunchedEffect(Unit) to search the communities when the composition is first composed. Searching for an empty string will return all communities from the database.

LaunchedEffect(key) runs the block of code whenever the composable enters recomposition, as long as the key you passed in changes between recompositions. Because you passed in Unit, which is a constant, it’s only going to run once — the first time the element is shown.

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 = localBackPressedDispatcher.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 localBackPressedDispatcher . localBackPressedDispatcher is a pre-built static CompositionLocal 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 CompositionLocal. Replace BackButtonAction() with the following:

@Composable
fun BackButtonAction(onBackPressed: () -> Unit) {
  CompositionLocalProvider(
    localBackPressedDispatcher provides (
        LocalLifecycleOwner.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 LocalLifecycleOwner 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 effects in Compose. Next, you’ll cover even more effects.

Effects in Compose

To understand the topic of 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 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(emptyList<String>()) }
  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.

You now learned all effect types, but there are functions that might help you use those effects for more specific situations. These functions create different kinds of states that should be used inside effect composables. The first function on the list is rememberUpdatedState().

RememberUpdatedState

When using LaunchedEffect, it is initiated every time the passed parameter changes. If you want to use a constant parameter that never changes, your effect will never restart which is a problem if you have values that need to be updated.

In this case, you can use rememberUpdatedState on your value that needs to be updated. This creates a reference to that value and allows it to update when the composable is recomposed. The example when you would need this approach is a splash screen:

@Composable
fun LandingScreen(onSplashFinished: () -> NetworkData) {

  val currentOnSplashFinished by rememberUpdatedState(onSplashFinished)

  LaunchedEffect(Unit) {
    delay(SplashWaitTimeMillis)
    currentOnSplashFinished()
  }
}

When the splash screen starts, you want to set a timeout for how long it should last and do some background work if you app requires it. When some of you background work is done, you might want to update the values in your composable which triggers the recomposition.

To update the value of your onSplashFinished lambda, you wrap it with rememberUpdatedState and then used inside the LaunchedEffect with Unit as a parameter. Since Unit is a constant value, the effect will never restart to ensure that your splash screen always has the same wait time, but your lambda will still be invoked with the latest value after the timeout is finished.

The next function that will help you when using effects is produceState.

ProduceState

Sometimes you want to do some work in the background and pass it down to the presentation layer. Remember that composable functions have States and any data used in composables needs to be converted into compose State in order to be used and survive the recomposition.

You can use produceState to write a function that fetches data and converts it directly into compose State. In the following code, you can see an example of loading books by author.

@Composable
fun loadBooks(author: String, booksRepository: BooksRepository): State<Result<List<Book>>> {
  return produceState(initialValue = Result.Loading, author, booksRepository) {
    
    val books = booksRepository.load(author)

    value = if (books == null) {
      Result.Error
    } else {
      Result.Success(books)
    }
  }
}

The function has two parameters, an author and booksRepository. ProduceState is called to create a coroutine and fetch the books and directly convert them to a composable State. If either of the two passed parameters change, the job will be canceled and relaunched with the new values.

This allows you to create a composable with the return type and call it from other composables like you would usually do in your presenters or viewmodels. Note that the name convention for composables with return type is to start with lowercase letter like other non-composable functions.

Migrate effects

If you used older version of Jetpack Compose, you might have have a few different effects that were not mentioned in this chapter. Those effects are now removed, but you can still achieve the same implementation using LaunchedEffect, DisposableEffect and SideEffect.

To migrate to the newer version, you can use this cookbook prepared for you:

// onActive without subject parameter
onActive {
  someFunction()
}

replace with:

LaunchedEffect(Unit) {
  someFunction()
}

You can replace onActive() without subject parameter by using LaunchedEffect with a constant value like Unit or true. This will ensure that the effect is used once, on the first composition.

Next, if you’re using it like so:

// onActive with subject parameter
onActive(parameter) {
  someFunction()
}

replace it with:

LaunchedEffect(parameter) {
  someFunction()
}

If you use subject parameter with your onActive(), you can just replace it with LaunchedEffect.

Then if you’re using something like:

// onActive with onDispose
onActive {
  val disposable = getData()
  
  onDispose {
    disposable.dispose()
  }
}

replace it with:

DisposableEffect(Unit) {
  val disposable = getData()

  onDispose {
    disposable.dispose()
  }
}

Like in the example without the subject parameter, you can replace onActive() with onDispose() inside by using DisposableEffect with a constant value like Unit or true.

Finally, if you’re using:

// onCommit without subject parameter
onCommit {
  someFunction()
}

replace it with:

SideEffect {
  someFunction()
}

You can replace onCommit() without a subject parameter by using SideEffect with a constant value like Unit or true. This will ensure that the effect is used on the first composition, and again for every recomposition. To use onCommit() with the subject parameter or onDispose() inside, use the same code as for onActive().

Key points

  • 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.
  • Use rememberUpdatedState() when you want to launch your effect only once but still be able to update the values.
  • Use produceState() to directly convert non-composable states into composable states.
  • Names of the composables with a return type should start with the lowercase letter.

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.

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.