- UI events
- ViewModel: One-off event antipatterns
- ViewModel: Events as State are an Antipattern
- Exercises in futility: One-time events in Android
- How to handle single-event in Jetpack Compose
User events are actions that the user performs in the UI, such as clicking a button, swiping a list, or entering text in a text field.
For example, if the user clicks a button to like an article, the ViewModel should handle that business logic.
fun NavGraphBuilder.articleListScreen() {
composable(route = ARTICLE_LIST_ROUTE) {
val viewModel: ArticleListViewModel = hiltViewModel()
val state: ArticleListState by viewModel.state.collectAsState()
ArticleListScreen(
state = state,
// Delegate business logic to the ViewModel
onArticleLike = { articleId -> viewModel.onArticleLike(articleId) }
)
}
}
Sometimes, when user clicks a button, we want to navigate to another screen. In this case, we can handle the navigation directly in the UI.
fun NavGraphBuilder.articleListScreen(
onNavigateToArticle: (String) -> Unit
) {
composable(route = ARTICLE_LIST_ROUTE) {
val viewModel: ArticleListViewModel = hiltViewModel()
val state: ArticleListState by viewModel.state.collectAsState()
ArticleListScreen(
state = state,
// Handle navigation in the UI
onNavigateToArticle = onNavigateToArticle,
)
}
}
In other cases, some user event may result in updating the UI state. No matter if this state is directly in the UI or in a separate plain class state holder, we can update it directly in the UI.
@Composable
fun ExpandableCard() {
var isExpanded by rememberSaveable { mutableStateOf(false) }
AnimatedVisibility(isExpanded) {
// User event can update the UI state directly
Card(onClick = { isExpanded = !isExpanded }) {
// ...
}
}
}
When you handle user events in the UI, don't put complex logic there. If you need to perform some decisions or calculations, create a separate plain class state holder.
@Composable
fun ArticleListScreen(articles: List<Article>) {
var displayedArticles by remember(articles) { mutableStateOf(articles) }
Button(
onClick = {
// Not a good thing to put in the UI
// Create a separate plain class state holder instead
displayedArticles = articles.sortedBy { it.date }
}
) {
Text("Sort by date")
}
}
Second type of UI events are ViewModel events. They usually occur when some business logic is done and the UI needs to be updated. Google has its own recommendations how to handle these situations.
For example when ViewModel completes some business operation with either success or failure, it should update the state to inform the UI about the result.
class LoginViewModel(private val loginUseCase: LoginUseCase) : ViewModel() {
var uiState by mutableStateOf<LoginUiState>(LoginUiState.FillingForm)
private set
fun submitLogin(credentials: Credentials) = viewModelScope.launch {
// We inform that we are submitting the form
// UI can display some loading indicator
uiState = LoginUiState.Submitting
val result = loginUseCase.login(credentials)
loginUseCase(credentials).onSuccess {
// We inform that user is logged in
// UI can go to a different screen
uiState = LoginUiState.LoggedIn
}.onFailure {
// We inform that credentials are invalid
// UI can display some error message
uiState = LoginUiState.InvalidCredentials
}
}
}
The best way to handle state changes in the UI is to use the LaunchedEffect
.
This way we can call navigation or display Snackbar or Dialog.
val snackbarHostState = remember { SnackbarHostState() }
val viewModel: LoginViewModel = hiltViewModel()
val state by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(state) {
when (state) {
is LoginUiState.InvalidCredentials -> {
snackbarHostState.showSnackbar(message = "Invalid credentials")
}
is LoginUiState.LoggedIn -> onNavigateToHome()
}
}
UI decides how to react to the state change. When ViewModel informs about the invalid credentials, UI may display a Snackbar. Snackbar is visible for a few seconds and then it disappears. Once it disappears, the UI can inform the ViewModel that the state change was handled. Then ViewModel resets the state to the initial state.
val snackbarHostState = remember { SnackbarHostState() }
val viewModel: LoginViewModel = hiltViewModel()
val state by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(state) {
when (state) {
is LoginUiState.InvalidCredentials -> {
snackbarHostState.showSnackbar(message = "Invalid credentials")
// Inform the ViewModel that Snackbar was shown
viewModel.invalidCredentialsMessageShown()
}
is LoginUiState.LoggedIn -> onNavigateToHome()
}
}
class LoginViewModel : ViewModel() {
var uiState by mutableStateOf<LoginUiState>(LoginUiState.FillingForm)
private set
fun invalidCredentialsMessageShown() {
// Reset the state to the initial state
uiState = LoginUiState.FillingForm
}
}
When ViewModel informs the UI about the state change, it should not tell the UI what to do. It is the UI's responsibility to decide how to react to the state change.
class LoginViewModel : ViewModel() {
var uiState by mutableStateOf<LoginUiState>(LoginUiState.FillingForm)
private set
fun submitLogin(credentials: Credentials) = viewModelScope.launch {
// Don't say how to present the loading
uiState = LoginUiState.ShowLoadingSpinner
val result = loginUseCase.login(credentials)
loginUseCase(credentials).onSuccess {
// Don't say where to navigate
uiState = LoginUiState.NavigateToHome
}.onFailure {
// Don't say how to present the error
uiState = LoginUiState.ShowInvalidCredentialsSnackbar
}
}
}
The community doesn't fully agree with Google's approach to handle ViewModel events by updating the state.
The alternative approach is to use separate side effects and a Channel
to inform the UI about what happened in the ViewModel.
In this approach, we define a separate model which represents all the side effect which may come from a single ViewModel.
data class LoginUiState(
val isSubmitting: Boolean = false,
)
sealed interface LoginSideEffect {
data object CompleteLogin : LoginSideEffect
data object NotifyAboutInvalidCredentials : LoginSideEffect
}
Next to the UI state, ViewModel contains a Channel
that we can use to send side effects to the UI.
class LoginViewModel(private val loginUseCase: LoginUseCase) : ViewModel() {
var uiState by mutableStateOf<LoginUiState>(LoginUiState())
private set
private val _sideEffects = Channel<LoginSideEffect>()
val sidesEffects = _sideEffects.receiveAsFlow()
fun submitLogin(credentials: Credentials) = viewModelScope.launch {
// Inform that we are submitting the form
// UI can display some loading indicator
uiState = uiState.copy(isSubmitting = true)
val result = loginUseCase.login(credentials)
loginUseCase(credentials).onSuccess {
// Inform that user is logged in
// UI can go to a different screen
_sideEffects.send(LoginSideEffect.CompleteLogin)
}.onFailure {
// Inform that credentials are invalid
// UI can display some error message
_sideEffects.send(LoginSideEffect.NotifyAboutInvalidCredentials)
}
// Inform that we finished submitting the form
// UI can hide the loading indicator
uiState = uiState.copy(isSubmitting = false)
}
}
In order to safely collect side effects in the UI we should scope it to the lifecycle and use a proper dispatcher. To not repeat this code in every screen, we can create a helper function.
@Composable
fun <T> CollectSideEffects(
sideEffects: Flow<T>,
onSideEffect: (T) -> Unit
) {
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(Unit) {
// Collect side effects only when the UI is in the STARTED state
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
// Guarantee that we handle side effect immediately without delays
withContext(Dispatchers.Main.immediate) {
sideEffects.collect(onSideEffect)
}
}
}
}
We can use our custom helper function to safely handle side effects in the UI.
val snackbarHostState = remember { SnackbarHostState() }
val viewModel: LoginViewModel = hiltViewModel()
// We handle side effect using our helper function
CollectSideEffects(viewModel.sidesEffects) { sideEffect ->
when (sideEffect) {
is LoginSideEffect.NotifyAboutInvalidCredentials -> {
snackbarHostState.showSnackbar(message = "Invalid credentials")
}
is LoginSideEffect.CompleteLogin -> onNavigateToHome()
}
}