android · 4 min read

Android MVI vs MVVM: state and events in practice

By Vivek Patel · Updated

MVVM and MVI overlap in modern Android applications. Both can expose immutable state through StateFlow. The practical distinction is whether actions and transitions are modeled explicitly as intents and reducers, or as methods on a ViewModel.

How the pieces connect

Intent / action→ ViewModel→ Immutable state→ Screen

An illustrative folder structure

This structure explains the architecture. Exact generated paths are shown in the source walkthrough below.

presentation/checkout/
  CheckoutScreen.kt
  CheckoutViewModel.kt
  CheckoutState.kt
  CheckoutIntent.kt   # explicit action model
  CheckoutEffect.kt   # transient UI communication

Use explicit intents when transitions get complex

A checkout flow with address changes, coupon validation, and payment retries benefits from a finite action vocabulary. A reducer makes transitions reviewable and testable. A static settings screen with two actions may be clearer with named ViewModel methods.

State and effects solve different problems

State describes what should render now. A transient notification or navigation request may be an effect. Decide what happens when the screen is stopped: a channel can queue events, while a shared flow can drop them without subscribers. For critical outcomes, represent the result in durable state and acknowledge it explicitly.

Do not let reducers perform network I/O

A pure reducer takes state and an action and returns state. The ViewModel or an effect handler performs asynchronous work, then dispatches a result. Test transition logic separately from repositories. Avoid reducers that mutate shared collections in place.

Choose the smallest consistent vocabulary

MVI adds ceremony that pays off in complex workflows and debugging. MVVM with immutable state is often sufficient for ordinary screens. The generator provides a practical MVI intent/state/effect starting point, not a full event-sourcing framework.

Inside the generated starter

Inspect what changes when the same Android starter switches to MVI. The generated implementation adds an intent entry point and an effect channel; it does not introduce a standalone pure reducer.

These source files are produced from the same templates used by the generator, with the configuration below. They are starter examples; the observations identify work still needed for a real feature.

app/src/main/java/com/example/myandroidapp/presentation/home/HomeIntent.kt

package com.example.myandroidapp.presentation.home
sealed interface HomeIntent { data object Increment : HomeIntent }

app/src/main/java/com/example/myandroidapp/presentation/home/HomeEffect.kt

package com.example.myandroidapp.presentation.home
sealed interface HomeEffect { data class Message(val text: String) : HomeEffect }

app/src/main/java/com/example/myandroidapp/presentation/home/HomeViewModel.kt

package com.example.myandroidapp.presentation.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
import com.example.myandroidapp.domain.repository.CounterRepository

class HomeViewModel(private val repository: CounterRepository) : ViewModel() {
    private val _state = MutableStateFlow(HomeUiState())
    val state = _state.asStateFlow()
    
    private val _effects = Channel<HomeEffect>(Channel.BUFFERED)
    val effects = _effects.receiveAsFlow()
    fun dispatch(intent: HomeIntent) { when (intent) { HomeIntent.Increment -> increment() } }
    
    fun increment() { viewModelScope.launch {
        try {
            val next = repository.load() + 1
            repository.save(next)
            _state.value = HomeUiState(next)
        } catch (error: CancellationException) { throw error } catch (error: Exception) { _state.value = _state.value.copy(error = "Unable to update counter") 
            _effects.send(HomeEffect.Message("Unable to update counter")) }
    } }
    class Factory(private val repository: CounterRepository) : ViewModelProvider.Factory {
        @Suppress("UNCHECKED_CAST")
        override fun <T : ViewModel> create(modelClass: Class<T>): T { require(modelClass.isAssignableFrom(HomeViewModel::class.java)); return HomeViewModel(repository) as T }
    }
}

What to notice

HomeIntent.Increment is dispatched to the ViewModel, which performs asynchronous work and changes HomeUiState. In MVVM the screen can call increment directly. The repository contract can remain identical in both versions.

An update failure sets the state error and sends a Message effect. Decide whether the screen should render an inline error, a transient notification, or both. Otherwise one failure can produce duplicate feedback.

The buffered channel is a transient communication mechanism, not a durable transaction log. A payment result should be recoverable from persisted application state after process death, not dependent on one effect delivery.

Extend it and verify the result

Add a Reset intent. First implement it as a ViewModel action, then extract a pure state transition function if the action set becomes difficult to review. Keep repository I/O outside that function. Test what happens to notifications when the screen is stopped and later resumed.

  • Every intent has a defined state result.
  • Reset and increment ordering is explicit.
  • One failure does not produce unintended duplicate notifications.
  • Critical results can be recovered without an effect subscriber.

These are exercises to add to your application tests, not claims that the starter already handles every case.

Try this configuration

MVI · Jetpack Compose · StateFlow · Retrofit

The generator includes a working counter example with a repository contract, selected dependencies, navigation, and local setup instructions. Extend the example around your own domain before shipping.

Configure your Android project →

For prerequisites, exact setup commands, expected behavior, and build troubleshooting, follow the android download-to-first-run walkthrough. Check the validation record for the configurations actually checked.

Continue reading

Primary reference

Android UI events guidance. The source walkthrough above describes AppScaffold’s implementation; platform documentation describes the broader APIs and recommendations.