MVVM vs MVI in Android — Which One and When
A practical comparison of MVVM and MVI architecture patterns in Android — how each works, when to use which, and how to avoid common pitfalls in both.
Every Android architecture discussion ends with “use MVVM” or “use MVI.” But rarely does anyone explain when one is better than the other, or that most apps use a blend of both.
This post breaks down both patterns with real code, compares them honestly, and helps you decide.
MVVM — Model-View-ViewModel
MVVM separates UI (View) from business logic (ViewModel) with observable state.
View (Activity/Fragment/Compose) ←── observes ──── ViewModel ←── calls ──── Repository/UseCase
│ │
└── sends events ──────────────────────────────┘
How it works
The ViewModel exposes state as observable streams (StateFlow, LiveData). The View observes and renders. User actions call ViewModel methods.
data class ProfileState(
val user: User? = null,
val isLoading: Boolean = false,
val error: String? = null
)
class ProfileViewModel(
private val userRepository: UserRepository
) : ViewModel() {
private val _state = MutableStateFlow(ProfileState())
val state: StateFlow<ProfileState> = _state.asStateFlow()
private val _name = MutableStateFlow("")
val name: StateFlow<String> = _name.asStateFlow()
private val _email = MutableStateFlow("")
val email: StateFlow<String> = _email.asStateFlow()
fun loadProfile(userId: String) {
viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true)
try {
val user = userRepository.getUser(userId)
_state.value = ProfileState(user = user)
_name.value = user.name
_email.value = user.email
} catch (e: Exception) {
_state.value = ProfileState(error = e.message)
}
}
}
fun updateName(name: String) {
_name.value = name
}
fun updateEmail(email: String) {
_email.value = email
}
fun saveProfile() {
viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true)
try {
userRepository.updateUser(name.value, email.value)
_state.value = _state.value.copy(isLoading = false)
} catch (e: Exception) {
_state.value = _state.value.copy(isLoading = false, error = e.message)
}
}
}
}
The View
@Composable
fun ProfileScreen(viewModel: ProfileViewModel = viewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
val name by viewModel.name.collectAsStateWithLifecycle()
val email by viewModel.email.collectAsStateWithLifecycle()
when {
state.isLoading -> CircularProgressIndicator()
state.error != null -> ErrorMessage(state.error!!)
else -> {
Column {
TextField(value = name, onValueChange = { viewModel.updateName(it) })
TextField(value = email, onValueChange = { viewModel.updateEmail(it) })
Button(onClick = { viewModel.saveProfile() }) {
Text("Save")
}
}
}
}
}
MVVM characteristics
- Multiple observable streams (one per piece of state)
- ViewModel methods map directly to user actions
- State updates are scattered across methods
- Simple to understand, easy to get started
MVI — Model-View-Intent
MVI adds structure by funneling all user actions through a single entry point and representing state as a single immutable object.
View → Intent (user action) → ViewModel → Reducer → State → View
How it works
- Intent: A sealed class representing every possible user action
- State: A single data class representing the entire screen
- Reducer: A pure function that takes current state + intent → new state
// State — single source of truth
data class ProfileState(
val user: User? = null,
val name: String = "",
val email: String = "",
val isLoading: Boolean = false,
val error: String? = null,
val isSaved: Boolean = false
)
// Intents — every possible user action
sealed class ProfileIntent {
data class LoadProfile(val userId: String) : ProfileIntent()
data class UpdateName(val name: String) : ProfileIntent()
data class UpdateEmail(val email: String) : ProfileIntent()
data object SaveProfile : ProfileIntent()
data object DismissError : ProfileIntent()
}
class ProfileViewModel(
private val userRepository: UserRepository
) : ViewModel() {
private val _state = MutableStateFlow(ProfileState())
val state: StateFlow<ProfileState> = _state.asStateFlow()
fun onIntent(intent: ProfileIntent) {
when (intent) {
is ProfileIntent.LoadProfile -> loadProfile(intent.userId)
is ProfileIntent.UpdateName -> _state.value = _state.value.copy(name = intent.name)
is ProfileIntent.UpdateEmail -> _state.value = _state.value.copy(email = intent.email)
is ProfileIntent.SaveProfile -> saveProfile()
is ProfileIntent.DismissError -> _state.value = _state.value.copy(error = null)
}
}
private fun loadProfile(userId: String) {
viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true)
try {
val user = userRepository.getUser(userId)
_state.value = _state.value.copy(
user = user,
name = user.name,
email = user.email,
isLoading = false
)
} catch (e: Exception) {
_state.value = _state.value.copy(isLoading = false, error = e.message)
}
}
}
private fun saveProfile() {
viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true)
try {
userRepository.updateUser(_state.value.name, _state.value.email)
_state.value = _state.value.copy(isLoading = false, isSaved = true)
} catch (e: Exception) {
_state.value = _state.value.copy(isLoading = false, error = e.message)
}
}
}
}
The View
@Composable
fun ProfileScreen(viewModel: ProfileViewModel = viewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.onIntent(ProfileIntent.LoadProfile("user-123"))
}
when {
state.isLoading -> CircularProgressIndicator()
state.error != null -> {
ErrorMessage(
message = state.error!!,
onDismiss = { viewModel.onIntent(ProfileIntent.DismissError) }
)
}
else -> {
Column {
TextField(
value = state.name,
onValueChange = { viewModel.onIntent(ProfileIntent.UpdateName(it)) }
)
TextField(
value = state.email,
onValueChange = { viewModel.onIntent(ProfileIntent.UpdateEmail(it)) }
)
Button(onClick = { viewModel.onIntent(ProfileIntent.SaveProfile) }) {
Text("Save")
}
}
}
}
}
MVI characteristics
- Single state object for the entire screen
- All actions go through
onIntent() - State transitions are explicit and traceable
- More structured, more boilerplate
Side-by-side comparison
| Aspect | MVVM | MVI |
|---|---|---|
| State representation | Multiple streams | Single state object |
| User actions | Direct method calls | Sealed class intents |
| State updates | Scattered across methods | Centralized in reducer |
| Boilerplate | Less | More (intent classes) |
| Debugging | Harder (multiple streams) | Easier (single state, logged intents) |
| Testing | Test each method | Test intent → state transitions |
| Learning curve | Lower | Higher |
| Compose fit | Good | Excellent (single state → single recompose) |
When MVVM is better
Simple screens
A settings page, a profile view, a static list. MVVM’s simplicity wins:
class SettingsViewModel : ViewModel() {
val darkMode = MutableStateFlow(false)
val fontSize = MutableStateFlow(16)
fun toggleDarkMode() { darkMode.value = !darkMode.value }
fun setFontSize(size: Int) { fontSize.value = size }
}
Adding MVI’s intent sealed class and reducer for this is overkill.
Forms with independent fields
When each field is independent and doesn’t affect others, multiple streams work fine:
val firstName = MutableStateFlow("")
val lastName = MutableStateFlow("")
val email = MutableStateFlow("")
Small teams / simple apps
MVVM is easier to teach and faster to implement. For apps with straightforward UI logic, the extra structure of MVI doesn’t pay off.
When MVI is better
Complex state machines
A checkout flow with loading, validation, payment processing, success/failure states. MVI makes every transition explicit:
sealed class CheckoutIntent {
data class UpdateAddress(val address: Address) : CheckoutIntent()
data object ValidateCart : CheckoutIntent()
data object ProcessPayment : CheckoutIntent()
data object RetryPayment : CheckoutIntent()
data class ApplyCoupon(val code: String) : CheckoutIntent()
}
You can see every possible action in one place. State transitions are predictable.
Screens with interdependent state
When one change affects multiple parts of the UI:
data class SearchState(
val query: String = "",
val results: List<Item> = emptyList(),
val filters: Set<Filter> = emptySet(),
val sortBy: SortOption = SortOption.RELEVANCE,
val isLoading: Boolean = false,
val hasMore: Boolean = false,
val page: Int = 0
)
With MVVM, updating query needs to also clear results, reset page, and trigger a new search. With MVI, the reducer handles all of this in one place.
Debugging and time-travel
MVI’s single state + explicit intents make debugging easier:
fun onIntent(intent: ProfileIntent) {
Log.d("MVI", "Intent: $intent")
Log.d("MVI", "State before: ${_state.value}")
// process intent...
Log.d("MVI", "State after: ${_state.value}")
}
Every state change is logged with its cause. Reproduce bugs by replaying the intent sequence.
Testing
MVI tests are declarative — given this state and this intent, expect this new state:
@Test
fun `UpdateName intent updates name in state`() {
val viewModel = ProfileViewModel(fakeRepository)
viewModel.onIntent(ProfileIntent.UpdateName("Alice"))
viewModel.state.value.name shouldBe "Alice"
}
@Test
fun `SaveProfile shows loading then success`() = runTest {
val viewModel = ProfileViewModel(fakeRepository)
viewModel.onIntent(ProfileIntent.UpdateName("Alice"))
viewModel.onIntent(ProfileIntent.SaveProfile)
// After completion
viewModel.state.value.isLoading shouldBe false
viewModel.state.value.isSaved shouldBe true
}
The pragmatic approach
Most production apps use a blend:
- Start with MVVM — it’s simpler and covers most screens
- Move to MVI for complex screens with interdependent state
- Keep the ViewModel contract consistent — whether you use methods or intents, the View shouldn’t care about the internal pattern
You can even mix them in the same app:
- Simple screens → MVVM (multiple StateFlows, direct methods)
- Complex screens → MVI (single state, intent sealed class)
The architecture police won’t come for you.
Common mistakes in both
MVVM: State explosion
Too many StateFlows become hard to manage:
// 10 separate flows — hard to coordinate
val name = MutableStateFlow("")
val email = MutableStateFlow("")
val isLoading = MutableStateFlow(false)
val error = MutableStateFlow<String?>(null)
// ... 6 more
When you have more than 3-4 related streams, consolidate into a single state object (which is basically MVI).
MVI: Intent for everything
Not every UI interaction needs an intent:
// Over-engineered — just use a direct callback
sealed class Intent {
data class TextFieldFocused(val field: String) : Intent()
data object KeyboardShown : Intent()
data class ScrollPositionChanged(val position: Int) : Intent()
}
UI-only interactions (focus, scroll, keyboard) don’t need to go through MVI. Keep intents for business-meaningful actions.
Both: One-time events as state
Navigation events, snackbars, and toasts are one-time — they shouldn’t be part of the state:
// Wrong — this stays true and re-triggers on config change
data class State(val navigateToHome: Boolean = false)
// Right — use SharedFlow for one-time events
private val _events = MutableSharedFlow<Event>()
val events: SharedFlow<Event> = _events.asSharedFlow()
Summary
| Choose MVVM when | Choose MVI when |
|---|---|
| Simple screens | Complex state machines |
| Independent fields | Interdependent state |
| Small teams | Need debugging/logging |
| Quick prototyping | Testability is critical |
| Straightforward logic | Multiple state transitions per action |
Both patterns are valid. Both work with Compose. The best architecture is the one your team can maintain and debug. Start simple, add structure when complexity demands it.