How to handle single-event in Jetpack Compose

Marco Cattaneo
5 min readDec 20, 2023

--

How can we handle a side effect inside our Jetpack Compose Application? Recently, in my company, we have started to migrate some workflows into Jetpack Compose, this doesn’t mean just writing new UI but also learning how the Compose Runtime works, in particular: the Recomposition.

Recomposition

Recomposition is the process of calling your composable functions again when inputs change. This happens when the function’s inputs change

This means, that every time an input State changes, Compose’s State Tracking System schedules a recomposition that draws the UI with the new fields. So, let's imagine having an example like this:

// State
data class ArticleState(
val title: String,
val auhtor: String
)

// Composable
fun ArticleView(state: ArticleState) {
Column {
Text(text = state.title)
Text(text = state.author)
}
}

If the state arguments change, the Compose Runtime schedules a recomposition that runs the ArticleView function’s block, so each line of code inside it will be executed.

This mechanism is perfect for creating a strong binding between a source of truth (the state) and the UI (composable functions). But what’s happening if we want to show a change just one time? Like prompting an Android’s Toast.

// State
data class ArticleState(
val title: String,
val auhtor: String,
val toastMessage: String?
)

// Composable
fun ArticleView(state: ArticleState) {
Column {
Text(text = state.title)
Text(text = state.author)
}

val context = LocalContext.current
if(state.toastMessage != null) {
Toast.makeText(context, state.toastMessage, Toast.LENGTH_LONG).show()
}
}

This is a bad idea because the makeText function will be called on each recomposition, so we will spam toast messages on each state’s change.

https://media.giphy.com/media/m59zqS6G8jE9Ip4daQ/giphy.gif

What do you think if we set the toastMessage variable to null after the rendering? Bad idea too, because this operation will prompt another recomposition that is unnecessary (and it sounds like a work-around).

First solution: LaunchedEffect

A good approach (also suggested by Google in their documentation) is using a LaunchedEffect.

LaunchedEffect restarts when one of the key parameters changes. However, in some situations you might want to capture a value in your effect that, if it changes, you do not want the effect to restart

// Composable
fun ArticleView(state: ArticleState) {
Column {
Text(text = state.title)
Text(text = state.author)
}

val context = LocalContext.current
LaunchedEffect(state.toastMessage) {
if(state.toastMessage != null) {
Toast.makeText(context, state.toastMessage, Toast.LENGTH_LONG).show()
}
}
}

In this implementation the LaunchedEffect block of code will be executed:

  • In the enter-composition, when the Composable function is rendered for the first time
  • and if the LaunchedEffect key argument changes, this will allow us to show the Toast message if the toastMessage String changes.

Second solution: collecting states via a Cold Flow

Using aLaunchedEffect could be a good solution, but it has a couple of limitations based on the context where it’s used.

Let’s assume that a ViewModel stores our State and survives during the navigation. Now we do these operations:

  • opening Screen A
  • prompt the Toast message in Screen A
  • opening Screen B
  • coming back to Screen A
Wrong side effect during navigation

When we come back to Screen A from Screen B we will see again the toast message, this is something we wouldn’t expect. Why does this happen?

Because (as I mentioned above) the Compose Runtime executes the LaunchedEffect’s block of code at least once, during the enter-composition, this means that the runtime will show the toast because it will find the previous toastMsg , given that it has persisted inside the ViewModel’s state via a hot Flow, like a StateFlow.

How can we solve this problem?

Well, first of all, we need a Flow which doesn’t persist states, so probably we need a cold flow instead of a hot one, then we need to make sure to consume these states just once. What I’m describing is a side-effect.

A side-effect is a change to the state of the app that happens outside the scope of a composable function. Due to composables’ lifecycle and properties such as unpredictable recompositions, executing recompositions of composables in different orders, or recompositions that can be discarded, composables should ideally be side-effect free.

How can we implement it? First, let’s define a data structure that models our Side Effects. It’s not mandatory, but it helps us to scale our application and supports more side effects in the future: a sealed interface is perfect for us.

sealed interface SideEffect {
data class ShowToast(val message: String) : SideEffect
}

Now we need to expose states through our ViewModel:

class MyViewModel : ViewModel() {

private val _sideEffectChannel = Channel<SideEffect>(capacity = Channel.BUFFERED)
val sideEffectFlow: Flow<SideEffect>
get() = _sideEffectChannel.receiveAsFlow()
// ...
}

In this case, we are using a Channel and we are going to expose it externally using a simple Flow . I decided to use receiveAsFlow() , because I want to allow just a single collector which consume these events at one time.

Now inside your View, you can collect it using a collector:

@Composable
fun ArticleView(myViewModel: MyViewModel) {
val lifecycleOwner = LocalLifecycleOwner.current
val context = LocalContext.current

LaunchedEffect(loginViewModel.sideEffectFlow) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
myViewModel.sideEffectFlow.collect { sideEffect ->
when (sideEffect) {
is SideEffect.ShowToast -> Toast.makeText(context, sideEffect.message, Toast.LENGTH_SHORT).show()
}
}
}
}
//...
}

In this way, we will consume all the events just once and all this will work lifecycle-aware because we are using the repeatOnLifecycle() . It works but it’s a bit too verbose, so let’s create a dedicated function that can wrap this logic more simply and reusable.

@Composable
fun <T : Any> SingleEventEffect(
sideEffectFlow: Flow<T>,
lifeCycleState: Lifecycle.State = Lifecycle.State.STARTED,
collector: (T) -> Unit
) {
val lifecycleOwner = LocalLifecycleOwner.current

LaunchedEffect(sideEffectFlow) {
lifecycleOwner.repeatOnLifecycle(lifeCycleState) {
sideEffectFlow.collect(collector)
}
}
}

Now we can use it inside our composable function:

@Composable
fun ArticleView(myViewModel: MyViewModel) {
val context = LocalContext.current

SingleEventEffect(myViewModel.sideEffectFlow) { sideEffect ->
when (sideEffect) {
is SideEffect.ShowToast -> Toast.makeText(context, sideEffect.message, Toast.LENGTH_SHORT).show()
}
}
//...
}

Source code

You can find the gist with the source code here.

I hope you have enjoyed reading this article! Please leave 1/2/tons of 👏 if you liked it, feedbacks are welcome as well!

--

--