Keep Your ViewModels Clean: A Guide to Event-Driven Architecture in Android

Bharat Kumar
5 min readSep 5, 2024

--

Hey guys I have come up with another Article on keeping your ViewModels clean and easy and the ViewModel Architecture I am showing is best for Compose.

Prerequisites

  1. Knowledge of basic Architecture
  2. Knowledge of basic Flows

Let's begin by understanding the terms you will see here for managing ViewModels.

The Core Concepts: Actions, Events, and States

Breaking down the user interactions into actions, events, and states is crucial for keeping your ViewModel clean and maintainable.

  1. Actions: These represent user inputs or UI interactions. Actions define what the user wants to do, such as changing a text field, clicking a button, or toggling a switch. By encapsulating all possible user interactions in a sealed class or enum, you centralize the logic for handling these actions.
  2. Events: Events are outputs from the ViewModel to the UI. They represent one-time effects, like showing a toast, navigating to another screen, or displaying an error message. Using a Channel or SharedFlow for events ensures that the UI receives these events without needing to observe them continuously.
  3. States: The state represents the current condition of the UI at any point in time. It holds all the necessary data that the UI needs to display, such as loading indicators, form fields, and error messages. States should be managed using StateFlow or LiveData to ensure that the UI is always in sync with the latest data.

How It All Works Together

When implementing an event-driven architecture in your ViewModel, the flow is simple. Let’s look at an example where you handle the sign-in process with actions, events, and states:

A Practical Example: SignIn ViewModel

Let’s look at an example where you handle the sign-in process with actions, events, and states:

@HiltViewModel
class SignInViewModel @Inject constructor(
private val repository: UserRepository
) : ViewModel() {

private val _uiStates = MutableStateFlow(SignInUiState())
val state = _uiStates.asStateFlow()

private val _uiEvents = Channel<SignUpUiEvents>()
val uiEvents = _uiEvents.receiveAsFlow()

fun onEvent(event: SignInUiActions) {
when (event) {
is SignInUiActions.NameChanged -> {
_uiStates.update { it.copy(name = event.name) }
}
is SignInUiActions.EmailChanged -> {
_uiStates.update { it.copy(email = event.email) }
}
is SignInUiActions.PasswordChanged -> {
_uiStates.update { it.copy(password = event.password) }
}
is SignInUiActions.SignIn -> {
signIn()
}
}
}

private fun signIn() {
viewModelScope.launch {
val response = repository.signIn(
SignInRequest(
email = _uiStates.value.email,
password = _uiStates.value.password
)
)
when (response) {
is NetworkResult.Error -> {
_uiEvents.send(SignUpUiEvents.OnError(response.message ?: "Unknown error"))
}
is NetworkResult.Success -> {
_uiEvents.send(SignUpUiEvents.SignInSuccess(response.data))
}
}
}
}

}

data class SignInUiState(
val name: String = "",
val email: String = "",
val password: String = "",
val isLoading: Boolean = false
)

sealed class SignInUiActions {
data class NameChanged(val name: String) : SignInUiActions()
data class EmailChanged(val email: String) : SignInUiActions()
data class PasswordChanged(val password: String) : SignInUiActions()
data object SignIn : SignInUiActions()
}

sealed class SignUpUiEvents {
data object None : SignUpUiEvents()
data class SignInSuccess(val signUpResponse: SignUpResponse) : SignUpUiEvents()
data class OnError(val message: String) : SignUpUiEvents()
}

User Interaction Triggers an Action: When a user interacts with the UI (e.g., types in a text field or clicks a button), the interaction triggers an action. The action is then sent to the ViewModel through a single function, such as onEvent.

The advantage of using it is that you have a single function to handle everything and you don't have to pass multiple lambdas in compose from child composables.

sealed class SignUpUiEvents {
data object None : SignUpUiEvents()
data class SignInSuccess(val signUpResponse: SignUpResponse) : SignUpUiEvents()
data class OnError(val message: String) : SignUpUiEvents()
}

fun onEvent(event: SignInUiActions) {
when (event) {
is SignInUiActions.NameChanged -> {
_uiStates.update { it.copy(name = event.name) }
}
is SignInUiActions.EmailChanged -> {
_uiStates.update { it.copy(email = event.email) }
}
is SignInUiActions.PasswordChanged -> {
_uiStates.update { it.copy(password = event.password) }
}
is SignInUiActions.SignIn -> {
signIn()
}
}
}

UI State Updates: After processing the action, the ViewModel updates the state. The UI observes this state and re-renders itself accordingly, ensuring that it always reflects the latest data.

Like here after Signup you can update the loading state to show some loader or something according to the requirement.

private fun signIn() {
viewModelScope.launch {
_uiStates.update { it.copy(isLoading = true) }
val response = repository.signIn(
SignInRequest(
email = _uiStates.value.email,
password = _uiStates.value.password
)
)
when (response) {
is NetworkResult.Error -> {
_uiStates.update { it.copy(isLoading = false) }
_uiEvents.send(SignUpUiEvents.OnError(response.message ?: "Unknown error"))
}
is NetworkResult.Success -> {
_uiStates.update { it.copy(isLoading = false) }
_uiEvents.send(SignUpUiEvents.SignInSuccess(response.data))
}
}
}
}

}

data class SignInUiState(
val name: String = "",
val email: String = "",
val password: String = "",
val isLoading: Boolean = false
)

Events Are Emitted: If the action requires the UI to respond to a one-time event (e.g., showing an error message), the ViewModel emits an event, which the UI handles and displays to the user.

For example, after Signing up, you can throw a success or an error event so they are not states as they don't need to be saved they are just one-time events.

private fun signIn() {
viewModelScope.launch {
_uiStates.update { it.copy(isLoading = true) }
val response = repository.signIn(
SignInRequest(
email = _uiStates.value.email,
password = _uiStates.value.password
)
)
when (response) {
is NetworkResult.Error -> {
_uiStates.update { it.copy(isLoading = false) }
_uiEvents.send(SignUpUiEvents.OnError(response.message ?: "Unknown error"))
}
is NetworkResult.Success -> {
_uiStates.update { it.copy(isLoading = false) }
_uiEvents.send(SignUpUiEvents.SignInSuccess(response.data))
}
}
}
}

}

data class SignInUiState(
val name: String = "",
val email: String = "",
val password: String = "",
val isLoading: Boolean = false
)

Why Use This Approach?

  1. Centralized Logic: By handling all user interactions through a single onEvent function, you centralize the logic for all UI-related actions in one place. This makes your code easier to maintain and understand.
  2. Simplified UI Code: Instead of passing multiple lambdas to handle different UI interactions, you only need to pass a single lambda that triggers onEvent. This simplifies your UI code and reduces the number of dependencies between UI components and ViewModels.
  3. Scalability: As your app grows, you can easily add new actions, states, and events without overcomplicating your ViewModel. This pattern scales well, even for complex UIs with many interactions.

If you found this article helpful, don’t forget to like, share, and leave your thoughts in the comments!

--

--