Learn the differences between Pure MVI, Reducers, and State Machines in Android and when to use each.
Introduction
Model–View–Intent (MVI) has become a popular architectural pattern for Android development, especially with Jetpack Compose. It enforces a single source of truth for the UI, which makes apps more predictable, testable, and easier to debug.
But here’s the catch: MVI itself comes in different flavors. Depending on the complexity of your screen or flow, you might implement:
- Pure MVI
- MVI with Reducer
- MVI with State Machine
Each approach has strengths, weaknesses, and ideal use cases. In this article, we’ll break them down with minimal Kotlin snippets and practical Android examples, so you’ll know when to use what.
1. Pure MVI
Pure MVI is the most straightforward:
- Intent = user action
- State = immutable snapshot of the UI
- ViewModel = transforms Intents into new State
It’s basically a direct mapping between user actions and state changes.
// State
data class CounterState(val count: Int = 0)
// Intent
sealed class CounterIntent {
object Increment : CounterIntent()
object Decrement : CounterIntent()
}
// ViewModel
class CounterViewModel : ViewModel() {
private val _state = MutableStateFlow(CounterState())
val state: StateFlow<CounterState> = _state
fun handleIntent(intent: CounterIntent) {
when (intent) {
CounterIntent.Increment -> _state.value =
_state.value.copy(count = _state.value.count + 1)
CounterIntent.Decrement -> _state.value =
_state.value.copy(count = _state.value.count - 1)
}
}
}
👉 When to use Pure MVI
- Simple screens (counters, forms, detail views).
- Great for clarity and quick development.
⚠️ Downside → ViewModel logic grows messy as complexity increases.
2. MVI with Reducer
The Reducer pattern introduces a pure function that takes the current State + Intent and returns a new State. This keeps the ViewModel clean and makes state transitions highly testable.
// Reducer function
fun reduce(state: CounterState, intent: CounterIntent): CounterState =
when (intent) {
CounterIntent.Increment -> state.copy(count = state.count + 1)
CounterIntent.Decrement -> state.copy(count = state.count - 1)
}
// ViewModel
class CounterReducerViewModel : ViewModel() {
private val _state = MutableStateFlow(CounterState())
val state: StateFlow<CounterState> = _state
fun dispatch(intent: CounterIntent) {
_state.value = reduce(_state.value, intent)
}
}
👉 When to use Reducer
- Medium complexity screens (forms, search, filters).
- When you want pure functions for easy unit testing.
- When your team wants to separate UI and business logic.
⚠️ Downside → Still a single state stream; doesn’t enforce valid sequences of states.
3. MVI with State Machine
What is a State Machine?
AÂ Finite State Machine (FSM)Â models your UI as a set of valid states and allowed transitions between them.
- You’re always in one valid state.
- You can only move via defined transitions.
- Impossible transitions are ignored.
Think of it as a subway map: you’re always at one station, and you can only move to connected stations.
This is extremely useful for complex flows, where you need to ensure the user cannot jump into invalid states.
Example: Checkout Flow
Let’s take an e-commerce checkout. The journey might look like this:
Idle → CollectingAddress → CollectingPayment → ProcessingPayment → Success
(or → Error if payment fails).
// States
sealed class CheckoutState {
object Idle : CheckoutState()
object CollectingAddress : CheckoutState()
object CollectingPayment : CheckoutState()
object ProcessingPayment : CheckoutState()
object Success : CheckoutState()
data class Error(val message: String) : CheckoutState()
}
// Intents
sealed class CheckoutIntent {
object StartCheckout : CheckoutIntent()
data class EnterAddress(val address: String) : CheckoutIntent()
data class EnterPayment(val card: String) : CheckoutIntent()
object ConfirmPayment : CheckoutIntent()
object Retry : CheckoutIntent()
}
class CheckoutViewModel : ViewModel() {
private val _state = MutableStateFlow<CheckoutState>(CheckoutState.Idle)
val state: StateFlow<CheckoutState> = _state
fun handle(intent: CheckoutIntent) {
when (val current = _state.value) {
CheckoutState.Idle -> when (intent) {
CheckoutIntent.StartCheckout ->
_state.value = CheckoutState.CollectingAddress
else -> Unit
}
CheckoutState.CollectingAddress -> when (intent) {
is CheckoutIntent.EnterAddress ->
_state.value = CheckoutState.CollectingPayment
else -> Unit
}
CheckoutState.CollectingPayment -> when (intent) {
is CheckoutIntent.EnterPayment ->
_state.value = CheckoutState.ProcessingPayment.also {
processPayment(intent.card)
}
else -> Unit
}
CheckoutState.ProcessingPayment -> when (intent) {
CheckoutIntent.Retry ->
_state.value = CheckoutState.CollectingPayment
else -> Unit
}
else -> Unit
}
}
private fun processPayment(card: String) {
viewModelScope.launch {
delay(2000)
if (card.startsWith("4")) {
_state.value = CheckoutState.Success
} else {
_state.value = CheckoutState.Error("Payment failed")
}
}
}
}
Job Offers
- Multi-step workflows (checkout, onboarding, authentication).
- When invalid states are dangerous (banking, healthcare).
- When debugging is easier if you see exact state transitions.
⚠️ Downside → More boilerplate, harder to maintain if overused.
When to Use What
- Pure MVI → For simple screens. Minimal, clear, and quick to implement.
- Reducer → For medium complexity. Clean separation, better testability.
- State Machine → For workflows with strict transitions. Prevents invalid UI states.
👉 A good rule of thumb: start simple, upgrade when complexity demands it.
Final Thoughts
MVI is not a one-size-fits-all pattern. Think of it as a spectrum of approaches:
- Pure MVI for clarity,
- Reducer for structure,
- State Machine for complex flows.
By picking the right level of abstraction, you keep your codebase clean, maintainable, and future-proof.

Dobri Kostadinov
Android Consultant | Trainer
Email me | Follow me on LinkedIn | Follow me on Medium | Buy me a coffee
Â
This article was previously published on proandroiddev.com



