MVI as a State Machine
From MVVM to MVI
MVVM exposes mutable state freely — any method on the ViewModel can change any piece of state at any time. This flexibility becomes a liability in complex screens where state transitions must be predictable. MVI tightens this by treating the UI as a finite state machine: the screen is always in exactly one state, and only explicit user intents can cause transitions.
The Three Pillars
- Intent: What the user wants to do — a tap, swipe, text input, or any interaction. In code, a sealed class where each subclass is one action.
- State: An immutable data class representing the entire screen at a point in time. One state object, not multiple streams.
- Effect: A one-time event — navigation, showing a snackbar, triggering a camera. Unlike State, Effects are consumed once and not replayed on recomposition.
The Event Loop
User Action → Intent → ViewModel processes → New State → View renders
↓
Effect → Navigation / Toast / Snackbar
This is a strict unidirectional cycle. The View never mutates state directly. It dispatches an Intent, and the ViewModel computes the next state deterministically.
Kotlin Code Example
// Intent — every possible user action
sealed interface LoginIntent {
data class EmailChanged(val email: String) : LoginIntent
data class PasswordChanged(val password: String) : LoginIntent
object LoginClicked : LoginIntent
}
// State — single immutable snapshot of the screen
data class LoginUiState(
val email: String = "",
val password: String = "",
val isLoading: Boolean = false,
val errorMessage: String? = null
)
// Effect — one-time side effects
sealed interface LoginEffect {
data class ShowToast(val message: String) : LoginEffect
object NavigateToHome : LoginEffect
}
class LoginViewModel(private val authRepository: AuthRepository) : ViewModel() {
private val _state = MutableStateFlow(LoginUiState())
val state: StateFlow<LoginUiState> = _state.asStateFlow()
private val _effect = Channel<LoginEffect>()
val effect: Flow<LoginEffect> = _effect.receiveAsFlow()
fun onIntent(intent: LoginIntent) {
when (intent) {
is LoginIntent.EmailChanged ->
_state.update { it.copy(email = intent.email) }
is LoginIntent.PasswordChanged ->
_state.update { it.copy(password = intent.password) }
is LoginIntent.LoginClicked -> login()
}
}
private fun login() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, errorMessage = null) }
try {
authRepository.login(_state.value.email, _state.value.password)
_effect.send(LoginEffect.NavigateToHome)
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, errorMessage = e.message) }
}
}
}
}
The ViewModel exposes a single onIntent entry point. Every user action goes through it. State is always consistent because there is exactly one state object.
Handling Side Effects and Navigation
The Side Effect Problem
State represents what the screen looks like. But some user actions produce outcomes that are not visual state — navigating to another screen, showing a toast, copying text to clipboard. If you embed these in state, they replay on every recomposition. A toast would fire again when the screen rotates.
Effects vs State
| Property | State | Effect |
|---|---|---|
| Replay on recomposition | Yes | No |
| Represents screen appearance | Yes | No |
| Consumed once | No | Yes |
| Survives config change | Yes | No |
Implementing Effects with Channel
class OrderViewModel(private val repository: OrderRepository) : ViewModel() {
private val _state = MutableStateFlow(OrderUiState())
val state: StateFlow<OrderUiState> = _state.asStateFlow()
// Channel guarantees each effect is delivered exactly once
private val _effects = Channel<OrderEffect>(Channel.BUFFERED)
val effects: Flow<OrderEffect> = _effects.receiveAsFlow()
fun onIntent(intent: OrderIntent) {
when (intent) {
is OrderIntent.PlaceOrder -> placeOrder()
is OrderIntent.ApplyCoupon -> applyCoupon(intent.code)
}
}
private fun placeOrder() {
viewModelScope.launch {
_state.update { it.copy(isPlacing = true) }
when (val result = repository.placeOrder(_state.value.toOrder())) {
is Result.Success -> {
_effects.send(OrderEffect.NavigateToConfirmation(result.orderId))
_effects.send(OrderEffect.ShowToast("Order placed!"))
}
is Result.Failure -> {
_state.update { it.copy(isPlacing = false, error = result.message) }
}
}
}
}
}
Collecting Effects in Compose
@Composable
fun OrderScreen(viewModel: OrderViewModel) {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is OrderEffect.NavigateToConfirmation ->
navController.navigate("confirmation/${effect.orderId}")
is OrderEffect.ShowToast ->
Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
}
}
}
// Render based on state...
}
The LaunchedEffect scope ensures effects are collected once per composition and not lost across recompositions.
MVI vs MVVM — When to Choose Which
MVVM When
- Simple screens with 2-3 state fields
- Quick prototyping where strictness slows you down
- Small teams familiar with LiveData patterns
- CRUD screens where state transitions are trivial
MVI When
- Complex screens with many interacting state fields
- Multiple user actions that must produce deterministic state transitions
- Shared ViewModel across multiple composables where state consistency is critical
- Navigation-heavy apps where side effects must not replay
- Teams that want to enforce a strict architecture contract
The Tradeoff
MVI adds boilerplate — every user action needs a sealed class entry, every state change goes through a reducer-like function. This ceremony pays off when the screen is complex enough that freeform state mutation becomes a liability.
// MVVM: Freeform state updates
fun onEmailChanged(email: String) { _email.value = email }
fun onPasswordChanged(pw: String) { _password.value = pw }
fun onLoginClicked() { login() }
// MVI: Single entry point, explicit intents
fun onIntent(intent: LoginIntent) {
when (intent) {
is LoginIntent.EmailChanged -> _state.update { it.copy(email = intent.email) }
is LoginIntent.PasswordChanged -> _state.update { it.copy(password = intent.password) }
is LoginIntent.LoginClicked -> login()
}
}
For a login screen, MVVM is simpler. For a checkout flow with coupon application, payment method selection, address editing, and order placement, MVI's discipline prevents state from going out of sync.
Quiz
1. What are the three core components of MVI?
2. Why are Effects separated from State in MVI?
3. Which data structure guarantees each MVI Effect is consumed exactly once?
4. In which scenario does MVI provide the most benefit over MVVM?
Flashcards
Question
What is an Intent in MVI?
Click to reveal answer
Answer
A sealed class representing every possible user action on a screen. Each subclass maps to one interaction (tap, text change, swipe).
Question
What is the difference between State and Effect in MVI?
Click to reveal answer
Answer
State is an immutable snapshot of the screen that replays on recomposition. Effect is a one-time event (navigation, toast) consumed once and not replayed.
Question
Why is MVI called a 'state machine' pattern?
Click to reveal answer
Answer
The screen is always in exactly one state. Only explicit Intents can trigger transitions to new states, making all UI behavior deterministic and traceable.
Question
When should you prefer MVVM over MVI?
Click to reveal answer
Answer
For simple screens with few state fields, quick prototypes, or small teams where MVI's sealed class ceremony adds unnecessary boilerplate.
Revision Notes
Key Takeaways
- 1. MVI models the UI as a finite state machine with deterministic transitions
- 2. Intents, State, and Effects are the three pillars — each serves a distinct purpose
- 3. Effects must use Channel to avoid replaying on recomposition
- 4. MVI's ceremony pays off in complex screens; MVVM is simpler for basic screens
Interview Tips
- • Explain MVI as 'UI as a state machine' — the screen always has one canonical state
- • Draw the Intent → ViewModel → State/Effect → View cycle on a whiteboard
- • Discuss when MVI's boilerplate is worth it vs when MVVM is sufficient
- • Know the difference between Channel and StateFlow for delivering one-time effects
Cheat Sheet
MVI Cheat Sheet
Components:
- Intent: Sealed class of user actions
- State: Single immutable data class per screen
- Effect: One-time events (navigation, toasts) via Channel
Flow: User → Intent → ViewModel → State/Effect → View
Key Rules:
- One
onIntent()entry point per ViewModel - State is always a single data class (no separate LiveData streams)
- Effects use
Channelto guarantee single consumption - ViewModel is a pure state reducer: Intent + Current State → New State
State vs Effect:
| State | Effect | |
|---|---|---|
| Replays | Yes | No |
| Represents UI | Yes | No |
| Consumed once | No | Yes |
Compose collection:
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { viewModel.effects.collect { ... } }