Skip to main content

MVI Pattern β€” Android Architecture

The Visla GPS Android app uses Model-View-Intent (MVI) across all 30+ ViewModels. Every user action flows through a single handle() entry point, making state changes predictable and testable.

Architecture Overview​

Data flow is unidirectional:

  1. User interacts with Compose UI
  2. UI dispatches an Intent via viewModel.handle(intent)
  3. handle() logs the intent, then calls reduce()
  4. reduce() matches the intent and runs business logic
  5. Business logic updates MutableStateFlow<UiState>
  6. Compose recomposes from the new state

Core Components​

BaseIntent β€” Marker Interface​

Every screen's intents must implement this marker interface:

// com.visla.vislagps.ui.base.BaseIntent

interface BaseIntent

IntentViewModel<I> β€” Base Class​

All ViewModels extend IntentViewModel<I> where I is the screen's sealed intent class:

// com.visla.vislagps.ui.base.IntentViewModel

abstract class IntentViewModel<I : BaseIntent> : ViewModel() {

// Single entry point for all user actions.
// Logs the intent name, then delegates to reduce().
open fun handle(intent: I) {
Logger.info(
"${this.javaClass.simpleName}: Handling intent",
mapOf("intent" to intent.javaClass.simpleName)
)
reduce(intent)
}

// Process the intent and update state.
// Keep synchronous β€” use handleAsync() for coroutines.
protected abstract fun reduce(intent: I)

// Launch a coroutine for async work with automatic error logging.
protected fun handleAsync(intent: I, block: suspend () -> Unit) {
Logger.info(
"${this.javaClass.simpleName}: Handling async intent",
mapOf("intent" to intent.javaClass.simpleName)
)
viewModelScope.launch {
try {
block()
} catch (e: Exception) {
Logger.error(
"${this.javaClass.simpleName}: Intent failed",
mapOf(
"intent" to intent.javaClass.simpleName,
"error" to (e.message ?: "unknown")
)
)
}
}
}
}

Three key methods:

MethodPurposeWhen to use
handle(intent)Public entry point. Logs intent, calls reduce().Called from Compose UI
reduce(intent)Abstract. Routes intents to private methods via when.Implement in every ViewModel
handleAsync(intent, block)Launches a coroutine in viewModelScope with try/catch logging.Use for network calls, DB ops

How to Define Intents​

Each screen defines a sealed class extending BaseIntent. Every possible user action becomes a subclass:

sealed class ChangePasswordIntent : BaseIntent {
// Field updates (carry data)
data class UpdateCurrentPassword(val password: String) : ChangePasswordIntent()
data class UpdateNewPassword(val password: String) : ChangePasswordIntent()
data class UpdateConfirmPassword(val password: String) : ChangePasswordIntent()

// Toggle actions (no data)
data object ToggleCurrentPasswordVisibility : ChangePasswordIntent()
data object ToggleNewPasswordVisibility : ChangePasswordIntent()
data object ToggleConfirmPasswordVisibility : ChangePasswordIntent()

// Async action
data object ChangePassword : ChangePasswordIntent()

// UI housekeeping
data object ClearError : ChangePasswordIntent()
}

Conventions:

  • Use data class when the intent carries parameters
  • Use data object for parameterless intents
  • Name intents as verbs: UpdateEmail, LoadDevices, ToggleVisibility
  • Group related intents: field updates, actions, UI housekeeping

How to Define UI State​

Use a data class for simple screens and a sealed class when the screen has distinct visual states (loading, success, error).

Data class approach (simple screens)​

Best when the screen always shows the same layout with varying data:

data class ChangePasswordUiState(
val currentPassword: String = "",
val newPassword: String = "",
val confirmPassword: String = "",
val showCurrentPassword: Boolean = false,
val showNewPassword: Boolean = false,
val showConfirmPassword: Boolean = false,
val isLoading: Boolean = false,
val error: String? = null,
val success: Boolean = false
) {
// Derived properties β€” computed from state, not stored
val passwordsMatch: Boolean
get() = newPassword == confirmPassword

val isValid: Boolean
get() = currentPassword.isNotBlank() &&
newPassword.length >= 8 &&
passwordsMatch &&
!isLoading

val newPasswordError: String?
get() = if (newPassword.isNotEmpty() && newPassword.length < 8) {
"Password must be at least 8 characters"
} else null

val confirmPasswordError: String?
get() = if (confirmPassword.isNotEmpty() && !passwordsMatch) {
"Passwords don't match"
} else null
}

Sealed class approach (complex screens)​

Best when the screen has distinct visual modes:

sealed class DevicesUiState {
object Loading : DevicesUiState()

data class Success(
val devices: List<DeviceWithPosition>,
val isWebSocketConnected: Boolean,
val licenseStatus: License?
) : DevicesUiState()

data class Error(
val message: String,
val devices: List<DeviceWithPosition> = emptyList()
) : DevicesUiState()

data class Refreshing(
val devices: List<DeviceWithPosition>,
val isWebSocketConnected: Boolean
) : DevicesUiState()
}

When to use which:

ApproachUse whenExample screens
data classAlways same layout, toggle flags for loading/errorLogin, ChangePassword, EditProfile
sealed classDistinct visual modes (loading skeleton vs content vs full-screen error)Devices, History, Notifications

Complete Example: ChangePasswordViewModel​

This is a simple, representative ViewModel that shows the full pattern:

1. Intents + UI State + ViewModel​

// All three live in the same file: ChangePasswordViewModel.kt

// --- Intents ---
sealed class ChangePasswordIntent : BaseIntent {
data class UpdateCurrentPassword(val password: String) : ChangePasswordIntent()
data class UpdateNewPassword(val password: String) : ChangePasswordIntent()
data class UpdateConfirmPassword(val password: String) : ChangePasswordIntent()
data object ToggleCurrentPasswordVisibility : ChangePasswordIntent()
data object ToggleNewPasswordVisibility : ChangePasswordIntent()
data object ToggleConfirmPasswordVisibility : ChangePasswordIntent()
data object ChangePassword : ChangePasswordIntent()
data object ClearError : ChangePasswordIntent()
}

// --- UI State ---
data class ChangePasswordUiState(
val currentPassword: String = "",
val newPassword: String = "",
val confirmPassword: String = "",
val showCurrentPassword: Boolean = false,
val showNewPassword: Boolean = false,
val showConfirmPassword: Boolean = false,
val isLoading: Boolean = false,
val error: String? = null,
val success: Boolean = false
) {
val passwordsMatch: Boolean
get() = newPassword == confirmPassword

val isValid: Boolean
get() = currentPassword.isNotBlank() &&
newPassword.length >= 8 &&
passwordsMatch &&
!isLoading

val newPasswordError: String?
get() = if (newPassword.isNotEmpty() && newPassword.length < 8) {
"Password must be at least 8 characters"
} else null

val confirmPasswordError: String?
get() = if (confirmPassword.isNotEmpty() && !passwordsMatch) {
"Passwords don't match"
} else null
}

// --- ViewModel ---
@HiltViewModel
class ChangePasswordViewModel @Inject constructor(
private val changePasswordUseCase: ChangePasswordUseCase
) : IntentViewModel<ChangePasswordIntent>() {

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

override fun reduce(intent: ChangePasswordIntent) {
when (intent) {
is ChangePasswordIntent.UpdateCurrentPassword ->
_uiState.value = _uiState.value.copy(currentPassword = intent.password, error = null)
is ChangePasswordIntent.UpdateNewPassword ->
_uiState.value = _uiState.value.copy(newPassword = intent.password, error = null)
is ChangePasswordIntent.UpdateConfirmPassword ->
_uiState.value = _uiState.value.copy(confirmPassword = intent.password, error = null)
is ChangePasswordIntent.ToggleCurrentPasswordVisibility ->
_uiState.value = _uiState.value.copy(showCurrentPassword = !_uiState.value.showCurrentPassword)
is ChangePasswordIntent.ToggleNewPasswordVisibility ->
_uiState.value = _uiState.value.copy(showNewPassword = !_uiState.value.showNewPassword)
is ChangePasswordIntent.ToggleConfirmPasswordVisibility ->
_uiState.value = _uiState.value.copy(showConfirmPassword = !_uiState.value.showConfirmPassword)
is ChangePasswordIntent.ChangePassword -> changePassword()
is ChangePasswordIntent.ClearError ->
_uiState.value = _uiState.value.copy(error = null)
}
}

private fun changePassword() {
if (!_uiState.value.isValid) return

viewModelScope.launch {
try {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
changePasswordUseCase(
currentPassword = _uiState.value.currentPassword,
newPassword = _uiState.value.newPassword
)
_uiState.value = _uiState.value.copy(isLoading = false, success = true)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Failed to change password"
)
}
}
}
}

2. Compose Screen​

@Composable
fun ChangePasswordScreen(
viewModel: ChangePasswordViewModel = hiltViewModel(),
onBack: () -> Unit
) {
val uiState by viewModel.uiState.collectAsState()

// React to one-shot events
LaunchedEffect(uiState.success) {
if (uiState.success) onBack()
}

Column {
// Each input dispatches an intent on change
TextInput(
value = uiState.currentPassword,
onValueChange = { viewModel.handle(ChangePasswordIntent.UpdateCurrentPassword(it)) },
label = "Current Password",
isPassword = true,
)

TextInput(
value = uiState.newPassword,
onValueChange = { viewModel.handle(ChangePasswordIntent.UpdateNewPassword(it)) },
label = "New Password",
isError = uiState.newPasswordError != null,
errorMessage = uiState.newPasswordError,
)

TextInput(
value = uiState.confirmPassword,
onValueChange = { viewModel.handle(ChangePasswordIntent.UpdateConfirmPassword(it)) },
label = "Confirm Password",
)

// Error display
uiState.error?.let { error ->
Text(text = error, color = colors.error)
}

// Submit button β€” enabled/loading driven by state
PrimaryButton(
text = "Change Password",
onClick = { viewModel.handle(ChangePasswordIntent.ChangePassword) },
enabled = uiState.isValid,
loading = uiState.isLoading,
)
}
}

Complex Example: DevicesViewModel​

DevicesViewModel demonstrates the pattern at scale β€” multiple state flows, real-time WebSocket data, lifecycle-aware refresh, and derived state flows for backward compatibility.

Key patterns in the complex case​

Sealed class UI state with exhaustive when:

override fun reduce(intent: DeviceIntent) {
when (intent) {
is DeviceIntent.LoadDevices -> loadDevices()
is DeviceIntent.RefreshDevices -> refreshDevices()
is DeviceIntent.PullToRefresh -> refresh()
is DeviceIntent.SelectDevice -> selectDevice(intent.deviceId)
is DeviceIntent.CheckLicense -> checkLicenseStatus(intent.onCanAdd, intent.onNeedUpgrade)
is DeviceIntent.ReconnectWebSocket -> reconnectWebSocket()
is DeviceIntent.UnclaimDevice -> unclaimDevice(intent.deviceId)
is DeviceIntent.ClearOperationResult -> _operationResult.value = null
}
}

Self-dispatching intents from init:

init {
// Observe DataStore for real-time WebSocket updates
viewModelScope.launch {
devicesInteractor.devicesFlow.collect { devices ->
// Update UI state when data source changes
}
}
// Load immediately on creation
handle(DeviceIntent.LoadDevices)
}

Derived StateFlows for gradual migration:

// Backward-compatible flows derived from the main uiState
val devices: StateFlow<List<Device>> = _uiState.map { state ->
when (state) {
is DevicesUiState.Success -> state.devices.map { it.device }
is DevicesUiState.Refreshing -> state.devices.map { it.device }
is DevicesUiState.Error -> state.devices.map { it.device }
DevicesUiState.Loading -> emptyList()
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())

DevicesViewModel also exposes additional backward-compatible derived StateFlows beyond devices: positions, isLoading, isRefreshing, errorMessage, licenseStatus, and devicesWithPositionsFlow. Each is mapped from the main _uiState sealed class using the same .map { ... }.stateIn(...) pattern.

Lifecycle-aware foreground refresh:

DevicesViewModel registers a DefaultLifecycleObserver on ProcessLifecycleOwner to automatically refresh devices when the app returns to the foreground. The observer skips the initial onStart (already handled by init) and debounces subsequent foreground events within 5 seconds.

One-shot events via separate StateFlow:

// In LoginViewModel β€” events that should be consumed once
private val _loginEvent = MutableStateFlow<LoginEvent?>(null)
val loginEvent: StateFlow<LoginEvent?> = _loginEvent.asStateFlow()

sealed class LoginEvent {
object Success : LoginEvent()
data class NeedVerification(val email: String) : LoginEvent()
data class NeedTermsAcceptance(val name: String) : LoginEvent()
}

// In the Compose screen:
LaunchedEffect(loginEvent) {
when (val event = loginEvent) {
is LoginEvent.Success -> {
viewModel.handle(LoginIntent.ClearEvent)
navigateToHome()
}
is LoginEvent.NeedVerification -> {
viewModel.handle(LoginIntent.ClearEvent)
navigateToVerification(event.email)
}
is LoginEvent.NeedTermsAcceptance -> {
viewModel.handle(LoginIntent.ClearEvent)
navigateToTermsAcceptance(event.name)
}
null -> {}
}
}

Error Handling​

In synchronous reduce​

For validation or simple state updates, set the error directly:

private fun login() {
val email = _uiState.value.email.trim()
if (email.isBlank()) {
_uiState.value = _uiState.value.copy(errorMessage = "Please enter email")
return
}
// proceed...
}

In async operations​

Wrap the coroutine in try/catch, always reset loading state:

private fun changePassword() {
viewModelScope.launch {
try {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
changePasswordUseCase(currentPassword, newPassword)
_uiState.value = _uiState.value.copy(isLoading = false, success = true)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Failed to change password"
)
}
}
}

Using handleAsync() (structured approach)​

For ViewModels that want automatic error logging without manual try/catch:

override fun reduce(intent: MyIntent) {
when (intent) {
is MyIntent.LoadData -> handleAsync(intent) {
_state.value = State.Loading
val result = loadDataUseCase()
_state.value = State.Success(result)
}
}
}

handleAsync catches exceptions and logs them with the intent name automatically. The trade-off is you lose custom error handling β€” use viewModelScope.launch with manual try/catch when you need to update error state for the UI.


Testing ViewModels​

Tests use MockK for mocking, kotlinx-coroutines-test for coroutine control, and interact through handle() just like the UI does.

Setup pattern​

@OptIn(ExperimentalCoroutinesApi::class)
class ChangePasswordViewModelTest {

private lateinit var viewModel: ChangePasswordViewModel
private lateinit var changePasswordUseCase: ChangePasswordUseCase
private val testDispatcher = StandardTestDispatcher()

@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
changePasswordUseCase = mockk(relaxed = true)
viewModel = ChangePasswordViewModel(changePasswordUseCase)
}

@After
fun tearDown() {
Dispatchers.resetMain()
}
}

Testing synchronous intents​

Dispatch intent, assert state immediately:

@Test
fun `UpdateCurrentPassword - updates current password`() {
viewModel.handle(ChangePasswordIntent.UpdateCurrentPassword("oldpass123"))

assertEquals("oldpass123", viewModel.uiState.value.currentPassword)
}

@Test
fun `ToggleCurrentPasswordVisibility - toggles visibility`() {
assertFalse(viewModel.uiState.value.showCurrentPassword)

viewModel.handle(ChangePasswordIntent.ToggleCurrentPasswordVisibility)
assertTrue(viewModel.uiState.value.showCurrentPassword)

viewModel.handle(ChangePasswordIntent.ToggleCurrentPasswordVisibility)
assertFalse(viewModel.uiState.value.showCurrentPassword)
}

Testing async intents​

Use runTest and advanceUntilIdle() to let coroutines complete:

@Test
fun `ChangePassword - calls use case with passwords`() = runTest {
viewModel.handle(ChangePasswordIntent.UpdateCurrentPassword("currentpass"))
viewModel.handle(ChangePasswordIntent.UpdateNewPassword("newpassword"))
viewModel.handle(ChangePasswordIntent.UpdateConfirmPassword("newpassword"))

viewModel.handle(ChangePasswordIntent.ChangePassword)
advanceUntilIdle()

coVerify { changePasswordUseCase("currentpass", "newpassword") }
}

@Test
fun `ChangePassword - failure sets error`() = runTest {
coEvery { changePasswordUseCase(any(), any()) } throws RuntimeException("Invalid current password")
viewModel.handle(ChangePasswordIntent.UpdateCurrentPassword("current"))
viewModel.handle(ChangePasswordIntent.UpdateNewPassword("newpassword"))
viewModel.handle(ChangePasswordIntent.UpdateConfirmPassword("newpassword"))

viewModel.handle(ChangePasswordIntent.ChangePassword)
advanceUntilIdle()

assertFalse(viewModel.uiState.value.success)
assertEquals("Invalid current password", viewModel.uiState.value.error)
}

Testing the base IntentViewModel​

The project includes IntentViewModelTest that verifies the base class contract:

@Test
fun `handle calls reduce with the intent`() {
viewModel.handle(TestIntent.SimpleAction)
assertEquals(TestIntent.SimpleAction, viewModel.reducedIntents[0])
}

@Test
fun `handleAsync handles exceptions without crashing`() = runTest(testDispatcher) {
viewModel.testHandleAsync(TestIntent.AsyncAction) {
throw RuntimeException("Test error")
}
advanceUntilIdle()
// No crash β€” exception is caught and logged
}

Creating a New Screen β€” Step by Step​

1. Define the intents​

sealed class MyScreenIntent : BaseIntent {
data object Load : MyScreenIntent()
data class UpdateName(val name: String) : MyScreenIntent()
data object Save : MyScreenIntent()
data object ClearError : MyScreenIntent()
}

2. Define the UI state​

data class MyScreenUiState(
val name: String = "",
val isLoading: Boolean = false,
val error: String? = null,
val saved: Boolean = false,
)

3. Create the ViewModel​

@HiltViewModel
class MyScreenViewModel @Inject constructor(
private val myUseCase: MyUseCase,
) : IntentViewModel<MyScreenIntent>() {

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

init {
handle(MyScreenIntent.Load)
}

override fun reduce(intent: MyScreenIntent) {
when (intent) {
is MyScreenIntent.Load -> load()
is MyScreenIntent.UpdateName -> _uiState.value = _uiState.value.copy(name = intent.name)
is MyScreenIntent.Save -> save()
is MyScreenIntent.ClearError -> _uiState.value = _uiState.value.copy(error = null)
}
}

private fun load() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true)
try {
val data = myUseCase.load()
_uiState.value = _uiState.value.copy(name = data.name, isLoading = false)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Failed to load"
)
}
}
}

private fun save() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
try {
myUseCase.save(_uiState.value.name)
_uiState.value = _uiState.value.copy(isLoading = false, saved = true)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Failed to save"
)
}
}
}
}

4. Build the Compose screen​

@Composable
fun MyScreen(
viewModel: MyScreenViewModel = hiltViewModel(),
onBack: () -> Unit,
) {
val uiState by viewModel.uiState.collectAsState()

LaunchedEffect(uiState.saved) {
if (uiState.saved) onBack()
}

Column {
TextInput(
value = uiState.name,
onValueChange = { viewModel.handle(MyScreenIntent.UpdateName(it)) },
)

uiState.error?.let { Text(it, color = colors.error) }

PrimaryButton(
text = "Save",
onClick = { viewModel.handle(MyScreenIntent.Save) },
loading = uiState.isLoading,
)
}
}

5. Write tests​

@OptIn(ExperimentalCoroutinesApi::class)
class MyScreenViewModelTest {

private val testDispatcher = StandardTestDispatcher()
private lateinit var myUseCase: MyUseCase
private lateinit var viewModel: MyScreenViewModel

@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
myUseCase = mockk(relaxed = true)
viewModel = MyScreenViewModel(myUseCase)
}

@After
fun tearDown() { Dispatchers.resetMain() }

@Test
fun `UpdateName updates state`() {
viewModel.handle(MyScreenIntent.UpdateName("Test"))
assertEquals("Test", viewModel.uiState.value.name)
}

@Test
fun `Save calls use case`() = runTest {
viewModel.handle(MyScreenIntent.UpdateName("Test"))
viewModel.handle(MyScreenIntent.Save)
advanceUntilIdle()

coVerify { myUseCase.save("Test") }
assertTrue(viewModel.uiState.value.saved)
}
}

Design Decisions​

Why MVI over MVVM?​

Standard MVVM with mutable state properties allows multiple code paths to mutate state independently, making it hard to reason about state transitions. MVI enforces:

  • Unidirectional data flow β€” state only changes through intents, never from the UI directly
  • Single entry point β€” every action goes through handle(), enabling centralized logging and debugging
  • Predictable state β€” given the same sequence of intents, you get the same state
  • Easier testing β€” test by dispatching intents and asserting state, no need to mock UI interactions

Why sealed classes for intents?​

// Kotlin's exhaustive when ensures you handle every intent
override fun reduce(intent: ChangePasswordIntent) {
when (intent) { // Compiler error if you miss a case
is ChangePasswordIntent.UpdateCurrentPassword -> ...
is ChangePasswordIntent.UpdateNewPassword -> ...
// Forget one? Compiler tells you.
}
}
  • Type safety β€” intents carry typed data, no stringly-typed event names
  • Exhaustive when β€” add a new intent, the compiler forces you to handle it everywhere
  • Self-documenting β€” the sealed class is the complete list of what a screen can do
  • IDE support β€” auto-complete, rename refactoring, find usages all work

Why StateFlow over LiveData?​

  • Coroutine-native β€” StateFlow integrates naturally with viewModelScope and suspend functions
  • Null safety β€” StateFlow always has a value (no initial null surprise)
  • Thread safety β€” StateFlow is safe to update from any coroutine context
  • Compose integration β€” collectAsState() is the idiomatic Compose way
  • Derived state β€” map(), combine(), stateIn() compose cleanly

Why the handleAsync wrapper?​

  • Structured concurrency β€” runs in viewModelScope, auto-cancelled when ViewModel clears
  • Automatic error logging β€” catches exceptions and logs intent name + error message
  • Consistency β€” every async intent gets the same logging format
  • Safety net β€” uncaught exceptions in coroutines crash the app; handleAsync prevents this

Most ViewModels in practice use viewModelScope.launch directly with manual try/catch for more granular error handling (setting error messages in UI state). handleAsync is useful when you want fire-and-forget behavior with automatic logging.


File Locations​

ComponentPath
BaseIntentapp/src/main/java/com/visla/vislagps/ui/base/BaseIntent.kt
IntentViewModelapp/src/main/java/com/visla/vislagps/ui/base/IntentViewModel.kt
ViewModelsapp/src/main/java/com/visla/vislagps/ui/screens/*ViewModel.kt
ViewModel Testsapp/src/test/java/com/visla/vislagps/ui/screens/*ViewModelTest.kt
Base class testsapp/src/test/java/com/visla/vislagps/ui/base/IntentViewModelTest.kt