UI events are actions that should be handled in the UI layer, either by the UI
or by the ViewModel. The most common type of events are user events. The user
produces user events by interacting with the app—for example, by tapping the
screen or by generating gestures. The UI then consumes these events using
callbacks such as onClick()
listeners.
The ViewModel is normally responsible for handling the business logic of a
particular user event—for example, the user clicking on a button to refresh some
data. Usually, the ViewModel handles this by exposing functions that the UI can
call. User events might also have UI behavior logic that the UI can handle
directly—for example, navigating to a different screen or showing a
Snackbar
.
While the business logic remains the same for the same app on different mobile platforms or form factors, the UI behavior logic is an implementation detail that might differ between those cases. The UI layer page defines these types of logic as follows:
- Business logic refers to what to do with state changes—for example, making a payment or storing user preferences. The domain and data layers usually handle this logic. Throughout this guide, the Architecture Components ViewModel class is used as an opinionated solution for classes that handle business logic.
- UI behavior logic or UI logic refers to how to display state changes—for example, navigation logic or how to show messages to the user. The UI handles this logic.
UI event decision tree
The following diagram shows a decision tree to find the best approach for handling a particular event use case. The rest of this guide explains these approaches in detail.

Handle user events
The UI can handle user events directly if those events relate to modifying the state of a UI element—for example, the state of an expandable item. If the event requires performing business logic, such as refreshing the data on the screen, it should be processed by the ViewModel.
The following example shows how different buttons are used to expand a UI element (UI logic) and to refresh the data on the screen (business logic):
Views
class LatestNewsActivity : AppCompatActivity() {
private lateinit var binding: ActivityLatestNewsBinding
private val viewModel: LatestNewsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
// The expand section event is processed by the UI that
// modifies a View's internal state.
binding.expandButton.setOnClickListener {
binding.expandedSection.visibility = View.VISIBLE
}
// The refresh event is processed by the ViewModel that is in charge
// of the business logic.
binding.refreshButton.setOnClickListener {
viewModel.refreshNews()
}
}
}
Compose
@Composable
fun NewsApp() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "latestNews") {
composable("latestNews") {
LatestNewsScreen(
// The navigation event is processed by calling the NavController
// navigate function that mutates its internal state.
onProfileClick = { navController.navigate("profile") }
)
}
/* ... */
}
}
@Composable
fun LatestNewsScreen(
viewModel: LatestNewsViewModel = viewModel(),
onProfileClick: () -> Unit
) {
Column {
// The refresh event is processed by the ViewModel that is in charge
// of the UI's business logic.
Button(onClick = { viewModel.refreshNews() }) {
Text("Refresh data")
}
Button(onClick = onProfileClick) {
Text("Profile")
}
}
}
User events in RecyclerViews
If the action is produced further down the UI tree, like in a RecyclerView
item or a custom View
, the ViewModel
should still be the one handling user
events.
For example, suppose that all news items from NewsActivity
contain a bookmark
button. The ViewModel
needs to know the ID of the bookmarked news item. When
the user bookmarks a news item, the RecyclerView
adapter does not call the
exposed addBookmark(newsId)
function from the ViewModel
, which would require
a dependency on the ViewModel
. Instead, the ViewModel
exposes a state object
called NewsItemUiState
which contains the implementation for handling the
event:
data class NewsItemUiState(
val title: String,
val body: String,
val bookmarked: Boolean = false,
val publicationDate: String,
val onBookmark: () -> Unit
)
class LatestNewsViewModel(
private val formatDateUseCase: FormatDateUseCase,
private val repository: NewsRepository
)
val newsListUiItems = repository.latestNews.map { news ->
NewsItemUiState(
title = news.title,
body = news.body,
bookmarked = news.bookmarked,
publicationDate = formatDateUseCase(news.publicationDate),
// Business logic is passed as a lambda function that the
// UI calls on click events.
onBookmark = {
repository.addBookmark(news.id)
}
)
}
}
This way, the RecyclerView
adapter only works with the data that it needs: the
list of NewsItemUiState
objects. The adapter doesn’t have access to the entire
ViewModel, making it less likely to abuse the functionality exposed by the
ViewModel. When you allow only the activity class to work with the ViewModel,
you separate responsibilities. This ensures that UI-specific objects like views
or RecyclerView
adapters don't interact directly with the ViewModel.
Naming conventions for user event functions
In this guide, the ViewModel functions that handle user events are named with a
verb based on the action that they handle—for example: addBookmark(id)
or
logIn(username, password)
.
Handle ViewModel events
UI actions that originate from the ViewModel—ViewModel events—should always result in a UI state update. This complies with the principles of Unidirectional Data Flow. It makes events reproducible after configuration changes and guarantees that UI actions won't be lost. Optionally, you can also make events reproducible after process death if you use the saved state module.
Mapping UI actions to UI state is not always a simple process, but it does lead to simpler logic. Your thought process shouldn't end with determining how to make the UI navigate to a particular screen, for example. You need to think further and consider how to represent that user flow in your UI state. In other words: don't think about what actions the UI needs to make; think about how those actions affect the UI state.
For example, consider the case of navigating to the home screen when the user is logged in on the login screen. You could model this in the UI state as follows:
data class LoginUiState(
val isLoading: Boolean = false,
val errorMessage: String? = null,
val isUserLoggedIn: Boolean = false
)
This UI reacts to changes to the isUserLoggedIn
state and navigates to the
correct destination as needed:
Views
class LoginViewModel : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
/* ... */
}
class LoginActivity : AppCompatActivity() {
private val viewModel: LoginViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { uiState ->
if (uiState.isUserLoggedIn) {
// Navigate to the Home screen.
}
...
}
}
}
}
}
Compose
class LoginViewModel : ViewModel() {
var uiState by mutableStateOf(LoginUiState())
private set
/* ... */
}
@Composable
fun LoginScreen(
viewModel: LoginViewModel = viewModel(),
onUserLogIn: () -> Unit
) {
val currentOnUserLogIn by rememberUpdatedState(onUserLogIn)
// Whenever the uiState changes, check if the user is logged in.
LaunchedEffect(viewModel.uiState) {
if (viewModel.uiState.isUserLoggedIn) {
currentOnUserLogIn()
}
}
// Rest of the UI for the login screen.
}
Consuming events can trigger state updates
Consuming certain ViewModel events in the UI might result in other UI state updates. For example, when showing transient messages on the screen to let the user know that something happened, the UI needs to notify the ViewModel to trigger another state update when the message has been shown on the screen. That UI state can be modeled as follows:
// Models the message to show on the screen.
data class UserMessage(val id: Long, val message: String)
// Models the UI state for the Latest news screen.
data class LatestNewsUiState(
val news: List<News> = emptyList(),
val isLoading: Boolean = false,
val userMessages: List<UserMessage> = emptyList()
)
The ViewModel would update the UI state as follows when the business logic requires showing a new transient message to the user:
Views
class LatestNewsViewModel(/* ... */) : ViewModel() {
private val _uiState = MutableStateFlow(LatestNewsUiState(isLoading = true))
val uiState: StateFlow<LatestNewsUiState> = _uiState
fun refreshNews() {
viewModelScope.launch {
// If there isn't internet connection, show a new message on the screen.
if (!internetConnection()) {
_uiState.update { currentUiState ->
val messages = currentUiState.userMessages + UserMessage(
id = UUID.randomUUID().mostSignificantBits,
message = "No Internet connection"
)
currentUiState.copy(userMessages = messages)
}
return@launch
}
// Do something else.
}
}
fun userMessageShown(messageId: Long) {
_uiState.update { currentUiState ->
val messages = currentUiState.userMessages.filterNot { it.id == messageId }
currentUiState.copy(userMessages = messages)
}
}
}
Compose
class LatestNewsViewModel(/* ... */) : ViewModel() {
var uiState by mutableStateOf(LatestNewsUiState())
private set
fun refreshNews() {
viewModelScope.launch {
// If there isn't internet connection, show a new message on the screen.
if (!internetConnection()) {
val messages = uiState.userMessages + UserMessage(
id = UUID.randomUUID().mostSignificantBits,
message = "No Internet connection"
)
uiState = uiState.copy(userMessages = messages)
return@launch
}
// Do something else.
}
}
fun userMessageShown(messageId: Long) {
val messages = uiState.userMessages.filterNot { it.id == messageId }
uiState = uiState.copy(userMessages = messages)
}
}
The ViewModel doesn't need to know how the UI is showing the message on the screen; it just knows that there's a user message that needs to be shown. Once the transient message has been shown, the UI needs to notify the ViewModel of that, causing another UI state update:
Views
class LatestNewsActivity : AppCompatActivity() {
private val viewModel: LatestNewsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
/* ... */
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { uiState ->
uiState.userMessages.firstOrNull()?.let { userMessage ->
// TODO: Show Snackbar with userMessage.
// Once the message is displayed and
// dismissed, notify the ViewModel.
viewModel.userMessageShown(userMessage.id)
}
...
}
}
}
}
}
Compose
@Composable
fun LatestNewsScreen(
snackbarHostState: SnackbarHostState,
viewModel: LatestNewsViewModel = viewModel(),
) {
// Rest of the UI content.
// If there are user messages to show on the screen,
// show the first one and notify the ViewModel.
viewModel.uiState.userMessages.firstOrNull()?.let { userMessage ->
LaunchedEffect(userMessage) {
snackbarHostState.showSnackbar(userMessage.message)
// Once the message is displayed and dismissed, notify the ViewModel.
viewModel.userMessageShown(userMessage.id)
}
}
}
Other use cases
If you think your UI event use case cannot be solved with UI state updates, you might need to reconsider how data flows in your app. Consider the following principles:
- Each class should do what they're responsible for, not more. The UI is in charge of screen-specific behavior logic such as navigation calls, click events, and obtaining permission requests. The ViewModel contains business logic and converts the results from lower layers of the hierarchy into UI state.
- Think about where the event originates. Follow the decision tree presented at the beginning of this guide, and make each class handle what they're responsible for. For example, if the event originates from the UI and it results in a navigation event, then that event has to be handled in the UI. Some logic might be delegated to the ViewModel, but handling the event can’t be entirely delegated to the ViewModel.
- If you have multiple consumers and you're worried about the event being consumed multiple times, you might need to reconsider your app architecture. Having multiple concurrent consumers results in the delivered exactly once contract becoming extremely difficult to guarantee, so the amount of complexity and subtle behavior explodes. If you're having this problem, consider pushing those concerns upwards in your UI tree; you might need a different entity scoped higher up in the hierarchy.
- Think about when the state needs to be consumed. In certain situations,
you might not want to keep consuming state when the app is in the
background—for example, showing a
Toast
. In those cases, consider consuming the state when the UI is in the foreground.