“MVVM or MVI?” sounds like a choice between two boxes. In a real Android codebase, it rarely is.
MVVM describes how responsibilities are divided: a ViewModel prepares state for the UI and keeps presentation logic out of composables. MVI describes a flow of information: events enter through a controlled path and produce a new state, usually through a reducer.
Those ideas can overlap. A ViewModel can accept events, reduce them into one
immutable UiState, and expose that state to Compose. Is that MVVM because it
uses a ViewModel, or MVI because it has intents and a reducer? The honest answer
may be both.
That overlap matters to me because the project history for InfoRange records MVI in its initial architecture, while the current Android application is structured with Clean Architecture and MVVM. Treating those labels as mutually exclusive would create a contradiction where there does not need to be one.
The more useful question is not which acronym describes the entire application. It is this:
Who authors the state on this screen, and how much transition discipline does that author need?
Start with the source of truth
InfoRange is an offline-first field application. Its local Room database is the
source of truth for the Android UI, and screens observe that data through Kotlin
Flow. Background workers fetch remote changes, write them to Room, and let the
database notify any active observers.
For many read-heavy screens, the ViewModel is therefore not inventing state. It is projecting durable local data into a shape Compose can render.
A simplified version looks like this:
class ObservationListViewModel( repository: ObservationRepository,) : ViewModel() {
val uiState: StateFlow<ObservationListUiState> = repository.observeObservations() .map { observations -> ObservationListUiState( observations = observations.map(Observation::toUi), isEmpty = observations.isEmpty(), ) } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), initialValue = ObservationListUiState(isLoading = true), )}This is recognizably MVVM: Compose observes state exposed by a ViewModel. It is also unidirectional: Room emits data, the ViewModel maps it, and the UI renders the result.
Adding an intent type and reducer would not automatically make this screen more
correct. If the only meaningful transition is “replace the list with the latest
database result,” a reducer may simply restate what Flow.map already expresses.
That does not make MVI bad. It means this ViewModel is mostly a projection, not an author.
A reducer earns its place when the ViewModel must decide
Other screens have a different shape.
An editor may hold text that has not been persisted. A multi-step flow may need to prevent invalid transitions. A screen may combine user input, validation, background updates, and a pending submission. In those cases, the ViewModel owns meaningful transient state and must decide which transition happens next.
That is where MVI-style discipline becomes valuable:
sealed interface EditEvent { data class FieldChanged(val value: String) : EditEvent data object SaveRequested : EditEvent data object SaveCompleted : EditEvent data class SaveFailed(val message: String) : EditEvent}
data class EditUiState( val field: String = "", val isSaving: Boolean = false, val error: String? = null,)
fun reduce(state: EditUiState, event: EditEvent): EditUiState = when (event) { is EditEvent.FieldChanged -> state.copy(field = event.value, error = null)
EditEvent.SaveRequested -> state.copy(isSaving = true, error = null)
EditEvent.SaveCompleted -> state.copy(isSaving = false)
is EditEvent.SaveFailed -> state.copy(isSaving = false, error = event.message) }The reducer now buys something concrete. Transitions are explicit. Every event has a defined effect. Tests can feed the same state and event into a pure function and expect the same result. There is one place to inspect when an impossible UI state appears.
The value comes from the screen’s transition complexity, not from the MVI label.
MVVM does not require uncontrolled mutable state
Comparisons between MVVM and MVI often weaken MVVM until the result is predetermined. MVVM gets described as several mutable values changed from unrelated coroutines, while MVI gets one immutable state and a carefully tested reducer.
That is not a fair comparison.
An MVVM screen can expose one immutable StateFlow<UiState>, accept user actions
through functions or typed events, and keep all mutations inside the ViewModel.
It can follow unidirectional data flow without adopting every piece of a formal
MVI contract.
Likewise, using names such as Intent, Result, and Reducer does not guarantee
good state management. If work can still race outside the reducer, or the state
object mixes durable domain data with unrelated one-off effects, the vocabulary
has not solved the design problem.
The useful comparison is therefore not loose MVVM against disciplined MVI. It is the minimum discipline each screen needs to make its transitions obvious and correct.
Five questions I would ask per screen
Before choosing a pattern, I would audit the screens rather than vote on an architecture for the whole application.
1. Where does durable state live?
If the UI is a projection of Room, prefer mapping the database Flow directly
into UI state. Do not create a second mutable copy without a reason.
2. Does the ViewModel own state the database does not?
Uncommitted form input, selection, validation, optimistic changes, and multi-step progress all increase the ViewModel’s authorship. The more it owns, the more valuable explicit events and transitions become.
3. Can asynchronous inputs compete?
User actions, database emissions, network results, timers, and background work may arrive in different orders. If order changes the correct result, use a single controlled transition path rather than relying on whichever coroutine resumes first.
4. Are invalid states easy to create?
If several booleans can express combinations the product should never show, model the workflow more deliberately. A sealed state hierarchy or reducer may make those impossible combinations harder to represent.
5. Is codebase-wide uniformity worth the fixed cost?
A consistent MVI contract can make navigation, testing, and code review more predictable across a large team. That benefit is real. So is the per-screen cost of events, effects, reducers, and plumbing. The balance depends on how many screens need the guarantees.
Clean Architecture does not settle the question
Clean Architecture answers questions about dependency direction and boundaries. It helps keep domain rules independent from Android details and makes feature modules easier to isolate.
It does not decide whether a presentation layer needs a reducer.
InfoRange’s multi-module structure separates application, feature, data, worker,
model, and shared UI concerns. That organization can support a simple
Room-to-StateFlow projection on one screen and a stricter event-driven state
machine on another. Module boundaries and presentation-state transitions solve
different problems.
The rule I would use
For a screen that mostly projects durable state:
- Keep Room or the repository as the source.
- Map its
Flowinto one immutable UI state. - Send user actions to clearly named ViewModel functions.
- Avoid duplicating state just to satisfy a pattern.
For a screen that authors substantial transient state:
- Define a closed set of events.
- Route transitions through one controlled path.
- Use a reducer or equivalent state machine when ordering matters.
- Test transitions independently from Android components.
If most screens in an application have that second shape, adopting MVI consistently may be worth the plumbing. If only a few do, apply the stronger state-machine discipline where it earns its keep.
The choice is not really MVVM versus MVI. A well-designed Android application may use a ViewModel, immutable state, unidirectional flow, and reducers where needed without fitting neatly into one box.
Choose the invariants first: one clear source of durable truth, explicit state ownership, and ordered transitions where order affects correctness. The acronym should describe the design that falls out of those decisions—not make the decisions for you.
This reasoning is grounded in the architecture of an offline-first Android app I led for rangeland monitoring and veterinary access in northern Kenya and Namibia. The InfoRange case study covers its Room and Kotlin Flow data path, background synchronization, multi-module structure, offline maps, and encrypted local storage.