A new way of handling one-off events in your Android application (and even more)

Roman Andrushchenko on 2025-03-11

A new way of handling one-off events in your Android application (and even more)

Spoiler

After reading this article, you will be able to do things like this: (without memory leaks)

Detailed documentation is available in the README file here.

The problem of handling one-off events

Hello everyone ;)

Almost every Android developer has encountered the problem of implementing one-off events in the MVVM or MVI architectural patterns. Examples of such events include:

fun asyncOperation() {
    viewModelScope.launch {
        try {
            doSomething() // suspending function
        } catch (e: CustomException) {
            // the problem: show toast with error message
        }
    }
}

Well-known solutions

Unfortunately, there is currently no standardized way of handling one-off events in Android development. You can encounter solutions based on Kotlin Channels or SharedFlow quite often. On the other hand, many developers, including ones from Google, consider one-off events to be an anti-pattern. Instead, they recommend using additional properties in a state class. More details on this can be found in the following articles: - https://medium.com/androiddevelopers/viewmodel-one-off-event-antipatterns-16a1da869b95 - https://proandroiddev.com/viewmodel-events-as-state-are-an-antipattern-35ff4fbc6fb6

From my side, I can add an additional point that the direct usage of the approaches described in the articles has some issues. In real projects, one-off events can be copy-pasted across different screens. Your Android app may have 20, 50, or even more screens, each of which would require writing similar code to display a Toast-message or execute a navigation command.

For example, let’s take the approach from the first article. In this case, each time you want to display a Toast-message, you will have to write the following code:

1) Declare additional properties in a state class:

data class UiState(
    ...
    val errorMessage: String? = null,
)

2) Inside the ViewModel, initiate the one-off event by changing the state:

private val _uiState = MutableStateFlow<UiState>(...)
val uiState: StateFlow<UiState> = _uiState
...

_uiState.update {
    it.copy(errorMessage = e.localizedMessage)
}

3) Add a method in the ViewModel to clear the one-off event after it has been successfully handled:

fun cleanUpErrorMessage() {
    _uiState.update {
        it.copy(errorMessage = null)
    } 
}

4) Handle the one-off event in the Composable-function and do not forget to clear it:

val uiState = viewModel.uiState.observeAsState()
uiState.errorMessage?.let { errorMessage ->
    val context = LocalContext.current
    LaunchedEffect(uiState) {
        Toast.makeText(context, errorMessage, Toast.LENGTH_SHORT).show()
        viewModel.cleanUpErrorMessage()
    }
}

As you see, the code above can be quite complex and not easy to understand. And even if we consider other ways of handling one-off events (based on Kotlin Channels, for example), the amount of code will be slightly less, but the repetition of similar operations will still remain. Plus, there might be additional nuances with dispatchers, which Roman Elizarov (ex-project Lead for Kotlin) once mentioned in a GitHub issue:

How to properly use Kotlin Channels for one-off events

The root cause of the problem

At its core, the problem of handling one-off events is a specific case of another problem that Android developers face when writing Android applications. This is the problem of interaction between objects with different lifecycle.

In the past, Android developers wrapped AsyncTasks in RetainFragment instances, used Loaders, etc. Later, the MVP architectural pattern became popular and even third-party libraries were written to allow the Presenter surviving a screen rotation (as the ViewModel does now).

Finally, the official solution from Google appeared: ViewModel, LiveData, and later StateFlow. LiveData & StateFlow implement communication between the screen and the ViewModel out of the box. Actually they gives you a mechanism of communicating between objects with different lifecycles (Fragment & ViewModel, Activity & ViewModel or Composable-function & ViewModel).

However, both LiveData and StateFlow are designed for state transfer, but not for one-off events.

A new approach of handling one-off events and even more

Now, let’s try to solve the problem of one-off events by addressing the root cause. In other words, the solution you will see next not only allows you to easily work with one-off events, but also gives you the ability to easily interact with components that have different lifecycle.

You will be able to access objects with a shorter lifecycle from objects with a longer lifecycle; safely, without memory leaks, and without violating the principles of Clean Architecture.

There is no need to directly use Kotlin Channels, SharedFlow, additional properties in the state class, or anything like that anymore.

The main approach to handling one-off events will be as follows: you initiate one-off events in the ViewModel through interfaces that are injected to the ViewModel’s constructor.

For example:

// interface for initiating one-off events
interface ToastMessages {
    fun showToast(message: String)
}

@HiltViewModel
class MyViewModel @Inject constructor(
    // inject the interface:
    private val toastMessages: ToastMessages,
) : ViewModel() {
    
    fun doSomething() {
        viewModelScope.launch { 
            try {
                // execute async operation here
            } catch (e: CustomException) {
                // initiate a one-off event if needed
                toastMessages.showToast(e.localizedMessage)
            }
        }
    }
}

By doing this, we eliminate boilerplate code, and in addition, the code becomes much more readable. Instead of this:

_uiState.update {
    it.copy(errorMessage = e.localizedMessage)
}

The code is replaced with:

toastMessages.showToast(e.localizedMessage)

It looks really cool, but implementing the interface in practice is a more complex task than it seems at first glance, because the implementation of the ToastMessages interface should not contain references to the Activity Context or any other objects with a lifecycle shorter than the ViewModel’s lifecycle. Otherwise, we’ll end up with a memory leak.

And here the Effects Plugin for Hilt DI Framework comes to the rescue, simplifying implementations of such interfaces.

Prerequisites (KSP + Hilt)

Make sure you have properly installed KSP and Hilt to your Android project:

Now you are ready to install the plugin.

Installation

Depending on whether you use Jetpack Compose in your project, the library installation may vary:

A simple example

Let’s go through the simplest example of displaying a Toast-message.

If you don’t use Jetpack Compose, you can create an instance of ToastMessagesImpl using a lazyEffect delegate:

@AndroidEntryPoint
class MainActivity : ComponentActivity() {

    private val toastMessagesImpl by lazyEffect {
        ToastMessagesImpl(this)
    }
    
    ...
    
}

In this case, any ViewModel will be able to work with the ToastMessages interface inside the Activity and all of its fragments.

A more complex example

Methods of the interface that you inject into the ViewModel constructor can return a result if they are suspending methods:

interface Dialogs {
    suspend fun showConfirmationDialog(message: String): Boolean
}

That is, this is no longer just an event, but a one-off request from the ViewModel to the screen, which can return a result or throw an exception.

Next, let’s write an implementation:

@HiltEffect
class DialogsImpl : Dialogs {

    private class DialogState(
        val message: String,
        val onResult: (Boolean) -> Unit,
    )

    private var dialogState by mutableStateOf<DialogState?>(null)

    // suspend method in the implementation class 
    // is automatically cancelled and re-executed again
    // when you rotate a device
    override suspend fun showConfirmationDialog(message: String): Boolean {
        check(dialogState == null) { "Dialog is already displayed" }
        return suspendCancellableCoroutine { continuation ->
            val onResult: (Boolean) -> Unit = {
                continuation.resume(it)
                dialogState = null
            }
            dialogState = DialogState(message, onResult)
            continuation.invokeOnCancellation {
                dialogState = null
            }
        }
    }

    @Composable
    fun Dialog() = dialogState?.let { currentDialogState ->
        AlertDialog(
            onDismissRequest = {
                currentDialogState.onResult(false)
            },
            confirmButton = {
                TextButton(
                    onClick = { currentDialogState.onResult(true) }
                ) {
                    Text("Yes")
                }
            },
            dismissButton = {
                TextButton(
                    onClick = { currentDialogState.onResult(false) }
                ) {
                    Text("No")
                }
            },
            title = { Text("Dialog Title") },
            text = { Text(currentDialogState.message) },
        )
    }
}

Usage in Activity / Composable functions:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
        val toastMessagesImpl = remember { ToastMessagesImpl(this) }
        val dialogsImpl = remember { DialogsImpl() }
        EffectProvider(toastMessagesImpl, dialogsImpl) {
            App()
        }
    }
}

@Composable
fun App() {
    // use getEffect<T> to obtain the instance 
    // of DialogsImpl and show a dialog
    // when the view-model sends a request:
    getEffect<DialogsImpl>().Dialog()
}

Now you can not only send one-off events from the ViewModel to the screen, but also you can receive the result of handling. This allows you, for example, displaying dialogs and processing the user’s selection result in just 3 lines of code:

fun doSomething() {
    viewModelScope.launch {
        val confirmed = dialogs.showConfirmationDialog("Remove system dir?")
        if (confirmed) {
            // ...
        }
    }
}

How it works

The generalized mechanism of this library works as follows: it allows objects with a longer lifecycle to interact with objects with a shorter lifecycle without memory leaks. The most likely scenario is calling UI-related methods from view-models (toast messages, navigation commands, dialogs, etc).

If an object with a shorter lifecycle is not available at the time of the call, the call is queued until the object becomes available.

There are three types of calls in total:

interface MyEffects {
    // simple effect (one-off event)
    fun launchCatDetails(cat: Cat)
    // effect which can return a result (suspend modifier is required)
    suspend fun showAlertDialog(message: String): Boolean
    // effect which can return an infinite number of results
    fun listenClicks(): Flow<String>
}

By default, when you annotate the implementation class with HiltEffect, its interface is installed to a Hilt ActivityRetainedComponent which allows you injecting the interface directly to a view-model constructor. And you can treat the interface injected to a view-model constructor as an event sender, and the implementation class created by lazyEffect delegate or by EffectProvider Composable function as an event handler.

If there is at least one active handler, it can process incoming events sent by the interface. So actually, when you use lazyEffect or EffectProvider, you connect an event handler to an event sender. If there is no active event handlers, events are added to a queue.

  1. lazyEffect { … } delegate can be used directly in activities and/or fragments. It automatically connects an event handler when an activity/fragment is started, and then disconnects it when the activity/fragment is stopped.
  2. EffectProvider { … } composable function is intended to be used in other Composable functions. It works almost in the same way as lazyEffect delegate, but also it automatically disconnects the effect implementation when EffectProvider composition is going to be destroyed. Also EffectProvider provides you an additional function getEffect<T>() which can be used for retrieving handlers.

Feel free to share your thoughts in the comments. I’m also open to suggestions, improvements, or bug reports. Thank you for your attention, and I wish you happy Android app development :)