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:
- Toast-messages
- Navigation commands
- Launching dialogs
- any other action that is triggered not by a state change, but by simply calling a method (e.g. Toast.makeText).
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:

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:
- /gradle/libs.versions.toml:
- /build.gradle:
- /app/build.gradle:
- Application class:
- AndroidManifest.xml:
- MainActivity:
Now you are ready to install the plugin.
Installation
Depending on whether you use Jetpack Compose in your project, the library installation may vary:
- /gradle/libs.versions.toml:
- /app/build.gradle:
A simple example
Let’s go through the simplest example of displaying a Toast-message.
- Declare a new interface:
- Use the interface in the ViewModel:
- Write an implementation of the interface. There is just one simple rule here: you need to add the HiltEffect annotation to the class that implements the interface:
- Connect the ToastMessagesImpl implementation on the Activity side. If your project uses Jetpack Compose, you can use EffectProvider. This needs to be done only once, and after that, any ViewModel from any screen will be able to work with the ToastMessages interface:
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:
- One-off event
- Suspend Call: can return a result (including an exception)
- Flow Call: can return multiple or even an infinite number of results
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.
- 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.
- 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 :)