On this page
Ask five Android developers whether they use MVVM or MVI and you'll get five definitions. Some teams call any app with a ViewModel "MVVM". Others call any app with a sealed class Intent "MVI". The arguments are often about naming, not engineering.
This article cuts through that. It's for intermediate developers who already know ViewModels and StateFlow and want to decide how to structure screen logic. You'll see the precise difference between the two patterns, the same feature implemented both ways, how each handles one-off events such as navigation and snackbars, and how they affect testing.
What MVVM and MVI actually mean on Android
MVVM (Model–View–ViewModel)
The View (Activity, Fragment or composable) observes state exposed by a ViewModel, and calls ViewModel functions in response to user actions. The Model is your data and business layer (repositories, use cases). In modern Android MVVM, the ViewModel usually exposes a single StateFlow<UiState> and public functions like onSearchChange(query) or retry().
MVI (Model–View–Intent)
MVI makes the flow even stricter. Every user action is an Intent (an object, not a function call; unrelated to Android's Intent class). All intents go through one entry point. State changes happen in a reducer, a pure function (oldState, result) -> newState. The View renders one immutable state object.
Google's recommended architecture, a ViewModel exposing immutable UI state through StateFlow with events flowing up from the UI, is already unidirectional data flow. MVVM done this way is very close to MVI. The real difference is how strictly you formalise intents and state transitions.
One feature, two implementations
The feature: a product search screen. The user types a query, results load after a short debounce, failures show an error with a retry button, and tapping a product navigates to its details. A shared repository interface keeps the comparison fair:
data class Product(val id: String, val name: String, val price: Double)
interface ProductRepository {
suspend fun search(query: String): List<Product> // throws IOException on network failure
}MVVM implementation
data class SearchUiState(
val query: String = "",
val isLoading: Boolean = false,
val results: List<Product> = emptyList(),
val error: String? = null,
)
class SearchViewModel(private val repository: ProductRepository) : ViewModel() {
private val _uiState = MutableStateFlow(SearchUiState())
val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow()
// One-off navigation events: buffered so they aren't lost, consumed once.
private val _navigation = Channel<String>(Channel.BUFFERED)
val navigation: Flow<String> = _navigation.receiveAsFlow()
private var searchJob: Job? = null
fun onQueryChange(query: String) {
_uiState.update { it.copy(query = query) }
searchJob?.cancel()
searchJob = viewModelScope.launch {
delay(300) // debounce: only search after typing pauses
search(query)
}
}
fun retry() {
viewModelScope.launch { search(_uiState.value.query) }
}
fun onProductClick(product: Product) {
viewModelScope.launch { _navigation.send(product.id) }
}
private suspend fun search(query: String) {
if (query.isBlank()) {
_uiState.update { it.copy(results = emptyList(), error = null, isLoading = false) }
return
}
_uiState.update { it.copy(isLoading = true, error = null) }
try {
val results = repository.search(query)
_uiState.update { it.copy(isLoading = false, results = results) }
} catch (e: IOException) {
_uiState.update { it.copy(isLoading = false, error = "Couldn't load results. Check your connection.") }
}
}
}This is clear and idiomatic. Each user action has a named function, state is immutable, and it is easy to read top to bottom. The weakness shows up as screens grow. State changes are scattered across many functions, so understanding every way isLoading can change means reading the whole class.
MVI implementation
// Everything the user can do
sealed interface SearchIntent {
data class QueryChanged(val query: String) : SearchIntent
data object Retry : SearchIntent
data class ProductClicked(val product: Product) : SearchIntent
}
// Everything that can happen as a result (internal to the ViewModel)
sealed interface SearchResult {
data class QueryUpdated(val query: String) : SearchResult
data object Loading : SearchResult
data class Loaded(val products: List<Product>) : SearchResult
data class Failed(val message: String) : SearchResult
data object Cleared : SearchResult
}
// One-off effects the UI should perform exactly once
sealed interface SearchEffect {
data class OpenProduct(val id: String) : SearchEffect
}
data class SearchState(
val query: String = "",
val isLoading: Boolean = false,
val results: List<Product> = emptyList(),
val error: String? = null,
)
// Pure: no coroutines, no repository, trivially unit-testable.
fun reduce(state: SearchState, result: SearchResult): SearchState = when (result) {
is SearchResult.QueryUpdated -> state.copy(query = result.query)
SearchResult.Loading -> state.copy(isLoading = true, error = null)
is SearchResult.Loaded -> state.copy(isLoading = false, results = result.products)
is SearchResult.Failed -> state.copy(isLoading = false, error = result.message)
SearchResult.Cleared -> state.copy(isLoading = false, results = emptyList(), error = null)
}class SearchMviViewModel(private val repository: ProductRepository) : ViewModel() {
private val _state = MutableStateFlow(SearchState())
val state: StateFlow<SearchState> = _state.asStateFlow()
private val _effects = Channel<SearchEffect>(Channel.BUFFERED)
val effects: Flow<SearchEffect> = _effects.receiveAsFlow()
private var searchJob: Job? = null
// The single entry point for everything the UI does.
fun process(intent: SearchIntent) {
when (intent) {
is SearchIntent.QueryChanged -> {
emit(SearchResult.QueryUpdated(intent.query))
searchJob?.cancel()
searchJob = viewModelScope.launch {
delay(300)
search(intent.query)
}
}
SearchIntent.Retry -> viewModelScope.launch { search(_state.value.query) }
is SearchIntent.ProductClicked ->
viewModelScope.launch { _effects.send(SearchEffect.OpenProduct(intent.product.id)) }
}
}
private suspend fun search(query: String) {
if (query.isBlank()) return emit(SearchResult.Cleared)
emit(SearchResult.Loading)
try {
emit(SearchResult.Loaded(repository.search(query)))
} catch (e: IOException) {
emit(SearchResult.Failed("Couldn't load results. Check your connection."))
}
}
private fun emit(result: SearchResult) = _state.update { reduce(it, result) }
}It's more code. In return, every possible state transition is listed in one when expression, the reducer can be tested without coroutines, and you could log every intent and result to reconstruct exactly how a user reached a buggy state.
The Compose UI barely changes
@Composable
fun SearchScreen(
viewModel: SearchMviViewModel,
onOpenProduct: (String) -> Unit,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val lifecycleOwner = LocalLifecycleOwner.current
// Collect one-off effects only while the screen is visible.
LaunchedEffect(viewModel, lifecycleOwner) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.effects.collect { effect ->
when (effect) {
is SearchEffect.OpenProduct -> onOpenProduct(effect.id)
}
}
}
}
Column(Modifier.fillMaxSize().padding(16.dp)) {
OutlinedTextField(
value = state.query,
onValueChange = { viewModel.process(SearchIntent.QueryChanged(it)) },
label = { Text("Search products") },
modifier = Modifier.fillMaxWidth(),
)
when {
state.isLoading -> CircularProgressIndicator(Modifier.padding(24.dp))
state.error != null -> Column(Modifier.padding(top = 16.dp)) {
Text(state.error!!, color = MaterialTheme.colorScheme.error)
TextButton(onClick = { viewModel.process(SearchIntent.Retry) }) { Text("Retry") }
}
else -> LazyColumn {
items(state.results, key = { it.id }) { product ->
ListItem(
headlineContent = { Text(product.name) },
supportingContent = { Text("₹%.2f".format(product.price)) },
modifier = Modifier.clickable { viewModel.process(SearchIntent.ProductClicked(product)) },
)
}
}
}
}
}The hard part: one-off events
Navigation, snackbars and toasts are the classic pain point in both patterns. If you store "navigate to product 42" in state, rotating the device may navigate again. If you use a hot SharedFlow without a buffer, events emitted while the UI isn't collecting are lost. Teams typically choose one of two approaches:
- A buffered
Channelexposed as a Flow, as in the examples above, collected withrepeatOnLifecycle. Simple and widely used. In rare edge cases an event can still be dropped if it is delivered exactly as the collector is cancelled, so keep effects idempotent and non-critical. - Model the event as state and "consume" it. Store, for example,
userMessage: String?in UI state and callonMessageShown()after displaying it. This is the approach Android's architecture guidance recommends, because state can't be lost.
Whichever you choose, choose it once for the whole codebase.
Testing: where MVI shines
class SearchReducerTest {
@Test
fun `failure stops loading and shows error`() {
val loading = SearchState(query = "phone", isLoading = true)
val result = reduce(loading, SearchResult.Failed("offline"))
assertFalse(result.isLoading)
assertEquals("offline", result.error)
assertEquals("phone", result.query) // unrelated fields untouched
}
}Reducer tests are plain JUnit: no dispatchers, no fakes, no waiting. MVVM ViewModels are also very testable with runTest and a fake repository, but you test transitions through the async code rather than in isolation.
MVVM vs MVI at a glance
| Aspect | MVVM (with UDF) | MVI |
|---|---|---|
| User actions | Public functions | Sealed Intent objects, one entry point |
| State updates | Anywhere in the ViewModel | Only via a reducer |
| Boilerplate | Low | Medium–high (Intent, Result, Effect, State) |
| Traceability | Good | Excellent: every transition is enumerable and loggable |
| Learning curve | Gentle | Steeper; teams need shared conventions |
| Best for | Most screens, CRUD apps, small and medium teams | Complex, highly interactive screens; large teams wanting strict consistency |
| Risk | State logic gets scattered as screens grow | Ceremony on simple screens; over-engineering |
When to use which: real scenarios
Settings or profile screen
MVVM. A handful of actions and simple state. MVI's Intent/Result layers would add files without adding clarity.
Checkout, chat or editor
MVI. Many interacting events (payment status, validation, retries, real-time updates) where an explicit reducer prevents impossible state combinations.
Mixed codebase, many teams
MVVM with UDF rules as the baseline, and MVI-style reducers allowed for complex screens. Consistency of state exposure matters more than the label.
Common mistakes with both patterns
- Multiple mutable state streams (
isLoading,results,erroras separate flows). They can disagree with each other. Prefer one immutable state object per screen. - Exposing
MutableStateFlowpublicly. The UI could then mutate state, which breaks unidirectional flow. - Side effects inside the reducer, such as network calls, logging or navigation. Reducers must be pure.
- Passing Context or Views into the ViewModel, which leaks Activities across configuration changes.
- Choosing MVI for the résumé rather than for the screen's complexity.
Frequently asked questions
Is MVI better than MVVM?
Neither is universally better. MVI gives stricter, more traceable state transitions at the cost of boilerplate. MVVM with a single immutable state is simpler and sufficient for most screens.
Does Google recommend MVVM or MVI?
Google's architecture guide doesn't mandate either name. It recommends state holders (usually ViewModels) exposing immutable UI state with unidirectional data flow. Both patterns can follow that guidance.
Do I need a library for MVI?
No. As shown above, Kotlin sealed types, StateFlow and a reducer function are enough. Libraries can add conventions and tooling, but also dependencies to maintain.
Can I mix MVVM and MVI in one app?
Yes, as long as the team agrees where each is used. A common approach is MVVM by default, with reducers for complex screens.
Where do use cases fit?
Use cases belong to an optional domain layer below the ViewModel. They are independent of whether the ViewModel follows MVVM or MVI. See Clean Architecture in Android.
Conclusion
MVVM and MVI aren't rival philosophies. They are two levels of strictness on the same unidirectional data flow. Start with MVVM and a single immutable UI state. Move to MVI's intents and reducers when a screen's state transitions get hard to reason about, and agree on one approach to one-off events across the whole team.
Next step: take one of your existing ViewModels and list every way its state can change. If the list is short, MVVM is serving you well. If it's long and scattered, extract a reducer. Then read Clean Architecture in Android: Practical Lessons to structure the layers beneath the ViewModel.
Official references
📱 Apps built by Yunok Tech
Real Android apps on Google Play, free to download.
Walzi4K wallpapers for lock and home screenGet it â–¸
NotiVaultNotification history: recover dismissed notificationsGet it â–¸
Ready to build real Android applications?
Architecture makes the most sense when you apply it to a real project and see why each boundary exists. Learn how maintainable Android applications are designed by building one.
Move beyond theory and strengthen your Android development skills with Yunoktech's Android course. Learn core concepts, practical implementation, modern Android development techniques, and the reasoning behind building maintainable applications.
Explore the Android course and take your next step as an Android developer.