SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC22:23:12
UP00:01
MOD · ARTICLE · ARCHITECTURES/N · AX-MVVM-VPUBLISHED
All Articles
Architecture · Updated 8 MIN

MVVM vs MVI: Choosing the Right Architecture for Your Android App

A practical comparison of Model-View-ViewModel and Model-View-Intent patterns for modern Android development with Jetpack Compose.

By Rocky Elsalaymeh · Founder & Principal Consultant, Strategia-X

Is MVVM or MVI the pattern Android recommends?

Neither, by name. This article used to say MVVM was Android's own pattern, with extensive documentation behind it. Search Google's Guide to app architecture and its UI layer page for the acronyms MVVM and MVI: neither appears on either page. What the guide recommends is two principles. The first: "assign a single source of truth (SSOT)" to each piece of data, where "only the SSOT can modify or mutate it". The second is unidirectional data flow: "state flows in only one direction", and "The events that modify the data flow in the opposite direction." Both MVVM and MVI, as Android teams practise them today, are ways of following those two principles with a ViewModel. The real difference between them is narrower than the debate suggests. It is one question: does the ViewModel expose its screen state as several streams or as one? Google has a documented answer to that question, and it is not "always one" or "always several".

MVVM: Several Streams

In the MVVM style most Android code uses, the ViewModel exposes each piece of screen state as its own observable holder, and the UI calls functions on the ViewModel. A profile screen might have one stream for the user, one for loading and one for the error. It is quick to write and easy to read. Its weakness is visible in the sample: three values that describe one situation are updated by three separate assignments. Nothing stops the UI from seeing "loading is false" and "user is null" and "error is null" together for a moment, a state the screen was never designed to draw. Note the catch. It names IOException, not Exception. A broad catch in a coroutine also swallows cancellation, which is covered in the coroutines deep dive.
kotlin
class ProfileViewModel(
    private val userRepo: UserRepository
) : ViewModel() {

    private val _user = MutableStateFlow<User?>(null)
    val user: StateFlow<User?> = _user.asStateFlow()

    private val _isLoading = MutableStateFlow(false)
    val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()

    private val _error = MutableStateFlow<String?>(null)
    val error: StateFlow<String?> = _error.asStateFlow()

    fun loadProfile(userId: String) {
        viewModelScope.launch {
            _isLoading.value = true
            _error.value = null
            try {
                _user.value = userRepo.getUser(userId)
            } catch (e: IOException) {
                _error.value = e.message
            } finally {
                _isLoading.value = false
            }
        }
    }
}

MVI: One Stream

MVI is stricter. The whole screen is one immutable state object, user actions are values (intents), and one function turns an intent into the next state. The UI can never observe half an update, because there is only one thing to observe. That is the property Google's guide argues for when it describes immutability: "immutable objects provide guarantees regarding the state of the application at an instant in time." It is also the benefit the guide attaches to unidirectional data flow: "There is a single source of truth for the UI." The cost is ceremony. Every action needs a type, and every screen needs a state class and a handler. An earlier version of this sample also had a RetryLoad intent that did nothing; an intent with no handler is the MVI version of a dead button, so the sample now remembers the last request.
kotlin
// State: one immutable object for the whole screen
data class ProfileState(
    val user: User? = null,
    val isLoading: Boolean = false,
    val error: String? = null
)

// Intent: every action the user can take
sealed interface ProfileIntent {
    data class LoadProfile(val userId: String) : ProfileIntent
    data object RetryLoad : ProfileIntent
    data object DismissError : ProfileIntent
}

class ProfileViewModel(
    private val userRepo: UserRepository
) : ViewModel() {

    private val _state = MutableStateFlow(ProfileState())
    val state: StateFlow<ProfileState> = _state.asStateFlow()

    private var lastUserId: String? = null

    fun onIntent(intent: ProfileIntent) {
        when (intent) {
            is ProfileIntent.LoadProfile -> loadProfile(intent.userId)
            ProfileIntent.RetryLoad -> lastUserId?.let(::loadProfile)
            ProfileIntent.DismissError ->
                _state.update { it.copy(error = null) }
        }
    }

    private fun loadProfile(userId: String) {
        lastUserId = userId
        viewModelScope.launch {
            _state.update { it.copy(isLoading = true, error = null) }
            try {
                val user = userRepo.getUser(userId)
                _state.update { it.copy(user = user, isLoading = false) }
            } catch (e: IOException) {
                _state.update {
                    it.copy(isLoading = false, error = e.message)
                }
            }
        }
    }
}

What Google Actually Says About One Stream or Several

The UI layer guide has a section titled "UI states: single stream or multiple streams?", and it is the closest thing to an official ruling on this debate. Its rule: "The key guiding principle for choosing between exposing UI state in a single stream or in multiple streams is the relationship between the items emitted." For one stream: "The biggest advantages of a single-stream exposure are convenience and data consistency: consumers of state always have the latest information available at any given time." For several, the guide names two cases: - Unrelated data. Here "the costs of bundling these disparate states together might outweigh the benefits, especially if one of these states updates more frequently than the other." - Too many fields. "The more fields there are in a UiState object, the more likely it is that the stream emits as a result of one of its fields being updated", and "every emission causes an update to the UI element."
QuestionPoints to one stream (MVI style)Points to several (MVVM style)
Do the values describe one situation?Yes: loading, data and error for the same requestNo: a profile and an unrelated notification badge
Does one value change far more often?NoYes: a timer or scroll position beside slow data
Does a change in one require a change in another?YesNo
The second and third columns are our reading of the guide's rule, applied to screens we have built. The quoted sentences are Google's.

Using Both Together

You do not pick one for the whole app. The decision is per screen, and it follows the table above. A settings page with independent toggles is fine with several streams. A checkout flow, where the address changes the delivery options and the delivery options change the total, wants one state object so that those three can never disagree. The boundary is the ViewModel. A composable collects state and calls back with events; it does not know or care how the ViewModel is organised inside. That means a screen can move from several streams to one without touching any other screen. Sealed interfaces are the natural type for the intents, and for a state that is better modelled as distinct cases than as nullable fields. Collect the state with collectAsStateWithLifecycle(), the lifecycle-aware collector.
kotlin
// The composable is the same shape for either pattern
@Composable
fun ProfileScreen(viewModel: ProfileViewModel) {
    // MVI style: one state object
    val state by viewModel.state.collectAsStateWithLifecycle()

    ProfileContent(
        user = state.user,
        isLoading = state.isLoading,
        error = state.error,
        onRetry = { viewModel.onIntent(ProfileIntent.RetryLoad) },
        onDismissError = {
            viewModel.onIntent(ProfileIntent.DismissError)
        }
    )
}
MOD · TAKEAWAYS5 POINTSSUMMARY

Key Takeaways

  1. 1Google's architecture guide names neither MVVM nor MVI. It recommends a single source of truth and unidirectional data flow.
  2. 2The practical difference is whether the ViewModel exposes screen state as several streams or as one immutable object.
  3. 3One stream gives consistency: the UI can never observe half an update.
  4. 4Several streams suit unrelated data, or data where one value updates far more often than the rest.
  5. 5Decide per screen. The ViewModel is the boundary, so the two styles coexist in one app.
MOD · FAQ3 ENTRIESANSWERED

Frequently Asked

Should I migrate from MVVM to MVI?

Only for screens where separate streams have produced inconsistent states, such as a spinner and an error showing together. Convert those screens to a single state object and leave the rest. The ViewModel is the boundary, so one screen can change style without any other screen noticing or needing a rewrite.

Can I use both MVVM and MVI in the same app?

Yes. Google's guidance is that the choice between one state stream and several depends on how the emitted items relate to each other, which is a per-screen question. Use one state object where values depend on each other, and separate streams where they are unrelated or update at very different rates.

Does MVI work with Jetpack Compose?

Yes. A composable collects one StateFlow with collectAsStateWithLifecycle() and sends events back as intents. One caution from the UI layer guide applies: every emission of the state object updates the UI that reads it, so a very large state object that changes often can cause more work than separate streams would.

MOD · BUILD · NEXTS/N · AX-CTA-0001READY

Ready to architect your next Android app?

ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.

ANDROID-ARCHITECT · CONSOLE
S/N · AX-1A-00001
STRATEGIA-X · ENGINEERED · IN · CALIFORNIA