PCSalt
YouTube GitHub
Back to Architecture
Architecture · 3 min read

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

  1. Intent: A sealed class representing every possible user action
  2. State: A single data class representing the entire screen
  3. 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

AspectMVVMMVI
State representationMultiple streamsSingle state object
User actionsDirect method callsSealed class intents
State updatesScattered across methodsCentralized in reducer
BoilerplateLessMore (intent classes)
DebuggingHarder (multiple streams)Easier (single state, logged intents)
TestingTest each methodTest intent → state transitions
Learning curveLowerHigher
Compose fitGoodExcellent (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:

  1. Start with MVVM — it’s simpler and covers most screens
  2. Move to MVI for complex screens with interdependent state
  3. 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 whenChoose MVI when
Simple screensComplex state machines
Independent fieldsInterdependent state
Small teamsNeed debugging/logging
Quick prototypingTestability is critical
Straightforward logicMultiple 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.