Why MVVM Often Beats MVI in Modern Android Compose Apps

This article compares MVVM and MVI in the Compose era, reviewing their histories, practical code examples, and trade‑offs, and concludes that MVVM usually provides sufficient predictability and simplicity while avoiding the boilerplate and complexity that MVI can introduce.

AndroidPub
AndroidPub
AndroidPub
Why MVVM Often Beats MVI in Modern Android Compose Apps

Jetpack Compose has pushed Android developers toward state‑driven UI and unidirectional data flow (UDF). This revived the MVVM vs MVI debate. Some claim MVI offers predictability, others argue MVVM requires less boilerplate while achieving the same goals. This article examines history, pros and cons, and explains why MVVM is often sufficient.

Historical Background

Original MVI (2014) : Introduced by André Staltz as a reactive UI architecture using a single data flow.

Original MVVM (2005) : Created by John Gossman for WPF/Silverlight, emphasizing observable objects bound to UI properties.

Early Android MVVM : Mimicked the pattern with ObservableField and later LiveData.

Modern MVVM (Compose era) : Google recommends an immutable UI state managed by StateFlow, following UDF (“state down, events up”).

Practical Comparison

Negative Example: “God‑like” Reducer

Developers sometimes equate MVI with a massive onEvent() or reducer function:

fun reducer(state: UiState, event: UiEvent): UiState {
    return when (event) {
        is UiEvent.Load -> state.copy(isLoading = true)
        is UiEvent.Login -> state.copy(userLoggedIn = true)
        // ... many more branches ...
    }
}

Complexity rises, losing predictability.

The reducer becomes a “god object” mixing navigation, UI, and domain logic.

Adding new screens inflates boilerplate.

This misuse treats MVI as a single huge handler.

Positive Example: Scoped Reducers

data class ItemListState(
    val isLoading: Boolean = false,
    val items: List<Item> = emptyList(),
    val error: String? = null
)

sealed class ItemListEvent {
    object Load : ItemListEvent()
    data class Loaded(val items: List<Item>) : ItemListEvent()
    data class Failed(val error: String) : ItemListEvent()
}

fun itemListReducer(state: ItemListState, event: ItemListEvent): ItemListState {
    return when (event) {
        is ItemListEvent.Load -> state.copy(isLoading = true)
        is ItemListEvent.Loaded -> state.copy(isLoading = false, items = event.items)
        is ItemListEvent.Failed -> state.copy(isLoading = false, error = event.error)
    }
}

Reducer is concise and focused on a specific feature.

State transitions are explicit and predictable.

Side effects (e.g., network calls) are handled elsewhere (middleware or use‑case layer).

The same approach can be achieved without adopting full MVI.

Negative Example: Overusing MVI on a Simple Screen

data class CounterState(val count: Int = 0)

sealed class CounterEvent {
    object Increment : CounterEvent()
    object Decrement : CounterEvent()
}

fun counterReducer(state: CounterState, event: CounterEvent): CounterState {
    return when (event) {
        CounterEvent.Increment -> state.copy(count = state.count + 1)
        CounterEvent.Decrement -> state.copy(count = state.count - 1)
    }
}

For a trivial counter, MVVM needs only two lines:

class CounterViewModel : ViewModel() {
    private val _count = MutableStateFlow(0)
    val count: StateFlow<Int> = _count

    fun increment() { _count.value++ }
    fun decrement() { _count.value-- }
}

Positive Example: Single‑State + Action MVVM

data class UiState(
    val isLoading: Boolean = false,
    val items: List<Item> = emptyList(),
    val error: String? = null
)

class ItemsViewModel(private val useCase: UseCase) : ViewModel() {
    private val _state = MutableStateFlow(UiState())
    val state: StateFlow<UiState> = _state

    fun loadItems() {
        _state.update { it.copy(isLoading = true, error = null) }
        viewModelScope.launch {
            try {
                val result = useCase.execute()
                _state.update { it.copy(isLoading = false, items = result, error = null) }
            } catch (e: Exception) {
                _state.update { it.copy(isLoading = false, error = e.message) }
            }
        }
    }

    fun onItemClicked(item: Item) {
        // handle selection or navigation
    }
}

Immutable single state makes data flow safe.

Actions map directly to ViewModel functions, avoiding an extra event layer.

No boilerplate sealed classes; code stays concise.

This pattern follows the same UDF principles as MVI but with less ceremony.

Discussion

Arguments for MVI

Structured reducer and explicit events improve predictability.

Scales better for large teams and highly complex screens.

Arguments for MVVM

Achieves Single State + Action + UDF without extra boilerplate.

Supported by Google’s guidelines and abundant community resources.

Properly scoped ViewModels can manage complexity effectively.

Final Thoughts

Using MVI for simple screens is overkill.

For complex screens, a well‑scoped MVVM implementation can be as effective as MVI.

Predictability stems from clear state management, not from the architecture name.

Compose and StateFlow enable MVVM to solve the problems MVI claims to address.

architectureAndroidMVVMComposeMVIStateFlow
AndroidPub
Written by

AndroidPub

Senior Android Developer & Interviewer, regularly sharing original tech articles, learning resources, and practical interview guides. Welcome to follow and contribute!

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.