MVI Pattern Explained

ARCHITECTURE › Patterns

Ask an Android interviewer to defend a complex screen's state management, and MVI usually comes up as the strict end of unidirectional data flow. The pitch is simple: one immutable state object describes everything the screen needs to render, and every user action is modeled as an object called an **Intent**, funneled through a single entry point.

- **Model**: one immutable state class holding everything the screen needs - **View**: renders that state, and nothing else - **Intent**: every tap, scroll, or event, expressed as a data object

State only ever flows one direction, down to the View. Intents only ever flow the other direction, up into the system. Nothing in between is allowed to mutate state directly, which is the whole point: a screen with a dozen mutable fields invites two of them getting out of sync, while a single immutable snapshot cannot be half-updated.

That single state object is normally a Kotlin data class, and it stays immutable, no var fields you flip in place. Every transition produces a brand-new instance instead of editing the old one.

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

Being a data class matters for two reasons. First, it gives you copy(), so producing the next state means naming only the fields that changed and carrying everything else forward untouched. Second, it gives you structural equals(), which you'll see pay off once the reducer is in the picture. Immutability itself is what makes a single state object trustworthy: nothing else in the app can reach in and change one field while you're mid-render, so whatever the View draws is always a complete, consistent snapshot, never a half-updated one.

User actions get the same immutable-object treatment. Instead of the View calling whichever ViewModel method it likes, every action is modeled as an **Intent** and passed through one function.

sealed interface MyIntent {
    data object LoadData : MyIntent
    data class Search(val query: String) : MyIntent
    data class ItemClicked(val id: String) : MyIntent
}

class MyViewModel : ViewModel() {
    private val _state = MutableStateFlow(UiState())
    val state: StateFlow<UiState> = _state.asStateFlow()

    fun onIntent(intent: MyIntent) {
        _state.update { current -> reduce(current, intent) }
    }
}

Modeling intents as a sealed interface rather than an enum or a bag of String constants matters because intents often carry a payload, a query string, a clicked id, and because a sealed hierarchy lets the compiler check a when exhaustively. Add a new intent subtype and every unguarded when (intent) fails to compile until you handle it. Everything routes through onIntent(), one doorway, so every interaction is explicit and traceable instead of scattered across a dozen public functions.

The function that actually turns an intent into the next state is the **reducer**, and its signature is the whole idea in one line: (state, intent) -> newState.

fun reduce(state: UiState, intent: MyIntent): UiState = when (intent) {
    is MyIntent.LoadData    -> state.copy(isLoading = true)
    is MyIntent.Search      -> state.copy(query = intent.query)
    is MyIntent.ItemClicked -> state.copy(selectedId = intent.id)
}

A reducer has to be **pure**: given the same state and the same intent, it always returns the same new state, and it never performs I/O, never touches the network or a database, never logs, never reads anything outside its two parameters. That purity is what makes a reducer trivial to unit test. You call it directly with a state and an intent and assert on what comes back, no mocking a repository, no waiting on a coroutine, no flakiness.

Because UiState is a data class, two separately-built instances with the same field values are equal to each other, not just reference-identical. That quietly matters once state is exposed as a StateFlow.

_state.update { it.copy(count = it.count) } // same value, structurally equal

StateFlow conflates: it compares each new value to the current one with equals(), and if they're equal it does not re-emit at all. So a reducer call that happens to compute the exact same state as before produces no downstream update. On the Compose side, the same equality check lets composables skip recomposition when their inputs haven't actually changed. None of this is special-cased for MVI, it falls straight out of using an immutable data class for state.

Put purity and immutability together and you get MVI's other headline feature: **time-travel debugging**. Every reduce() call produces a brand-new, fully-formed snapshot, so you can record the ordered list of states a screen passed through and step back through it, or replay the same sequence of intents later to reproduce a bug exactly.

data class UiState(val count: Int = 0, val isLoading: Boolean = false)

fun reduce(state: UiState, intent: MyIntent): UiState = when (intent) {
    is MyIntent.Increment  -> state.copy(count = state.count + 1)
    is MyIntent.SetLoading -> state.copy(isLoading = intent.value)
}

Nothing here is mutated in place, so nothing about an earlier snapshot changes once a later one exists. That's what makes logging every (state, intent) -> newState step, then diffing or replaying them, actually reliable. In MVVM, where several fields might be mutated independently, there's no single clean snapshot to log in the first place.

A reducer can't stay pure and also make a network call, since a suspend function is I/O and depends on when it happens to resolve. So async work runs **outside** the reducer, and its outcome re-enters the loop as a new intent that the reducer then folds in.

fun onIntent(intent: MyIntent) {
    when (intent) {
        is MyIntent.LoadUser -> viewModelScope.launch {
            _state.update { it.copy(isLoading = true) }
            val user = userRepo.fetchUser(intent.id)        // side effect, outside the reducer
            _state.update { reduce(it, MyIntent.UserLoaded(user)) }
        }
        is MyIntent.UserLoaded -> _state.update { reduce(it, intent) }
    }
}

reduce() itself never touches userRepo, it only ever sees the already-fetched user packaged inside UserLoaded. The same rule applies to anything else nondeterministic, including the system clock. A reducer that calls System.currentTimeMillis() to stamp a submission time is not pure, since the same state and intent would then produce a different result depending on when you called it. The fix is identical to the network case: capture the timestamp where the side effect happens, carry it on the intent, and let the reducer just copy it across.

A screen's state doesn't have to come from one place. When it depends on more than one source, say a user repository and a settings flow, MVI still wants exactly one immutable UiState, so you fold the sources together rather than exposing one StateFlow per source.

val uiState: StateFlow<UiState> = combine(
    userRepository.userFlow,
    settingsRepository.settingsFlow
) { user, settings -> UiState(userName = user.name, darkMode = settings.darkMode) }
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState())

For one-shot suspend calls instead of ongoing flows, the same idea looks like kicking off both concurrently and dispatching a single combined intent once they've both resolved, regardless of which one finished first, rather than reducing them one at a time. On the View side, that one UiState is collected lifecycle-aware and intents are handed back up through a lambda:

@Composable
fun MyScreen(viewModel: MyViewModel = hiltViewModel()) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    MyContent(state = state, onIntent = viewModel::onIntent)
}

That's the whole MVI loop realized natively in Compose: state down through collectAsStateWithLifecycle(), intents up through a plain lambda.

Not everything belongs in state, though. A one-off event, like showing a snackbar once, doesn't belong sitting in UiState forever the way isLoading does. It's tempting to reach for a separate stream of UI commands the ViewModel pushes to the View, but Android's own architecture guidance warns against that: events emitted from the ViewModel toward the UI can be lost or double-handled across configuration changes and process death, because the UI side isn't guaranteed to be listening at the moment they fire.

The recommended approach is to keep a single source of truth: handle the event inside the ViewModel, and fold its outcome into the existing uiState as a consumable field.

data class UiState(
    val snackbarMessage: String? = null // View shows it, then sends a ClearSnackbar intent
)

The View reads snackbarMessage, shows it, and dispatches an intent that clears it back to null. State still only flows one direction, and there's still exactly one place, uiState, that owns the truth.

Folding events into state is the default, but for a genuinely one-shot effect where nothing should ever fire twice, a navigation command, an 'item added to cart' confirmation, a plain field in UiState backed by StateFlow is actually the wrong tool. StateFlow is hot and conflating: it replays its current value to every new collector, so a fresh subscription after a configuration change would re-fire the same navigation.

// BAD: replays on every new collector, nav re-fires after rotation
private val _nav = MutableStateFlow<NavEvent?>(null)

// GOOD: a Channel hands each event to a consumer exactly once
private val _navEvents = Channel<NavEvent>(Channel.BUFFERED)
val navEvents: Flow<NavEvent> = _navEvents.receiveAsFlow()

viewModelScope.launch { _navEvents.send(NavEvent.GoToDetail(id)) }

The same pattern covers any effect that should announce itself once without ever touching state, like confirming an item was added to a cart. This is the common escape hatch used alongside the fold-into-state approach from the last chunk: reach for it specifically when replay-on-resubscribe would be wrong, and keep using uiState for everything else.

MVI and MVVM aren't rivals so much as one is a stricter version of the other; MVI is usually implemented on top of a ViewModel, not instead of one. The difference is how tightly interaction is constrained.

// MVVM: many entry points the View is free to call
class MvvmViewModel : ViewModel() {
    fun loadData() { ... }
    fun onSearch(query: String) { ... }
    fun onItemClicked(id: String) { ... }
}

// MVI: one entry point, one immutable state
class MviViewModel : ViewModel() {
    fun onIntent(intent: MyIntent) { ... }
}

MVVM's flexibility is also where it can go wrong on a complex screen: with many entry points, two of them can run close together and leave state in an inconsistent partial update. MVI trades that flexibility for a single funneled channel and a pure reducer, more ceremony to set up, but every transition is explicit and reproducible, exactly the property that gave you time-travel debugging a few chunks back.

None of this is free. A state class, a sealed intent hierarchy, a reducer, and effect plumbing is real ceremony compared to a handful of ViewModel functions. So the question an interviewer actually wants answered isn't 'is MVI good', it's 'when do you reach for it'.

It earns its cost on **complex, highly stateful screens**: checkout flows, multi-step forms, anything where several fields change together and bugs come from inconsistent partial updates. That's exactly where explicit, replayable transitions pay for themselves, you can log every (state, intent) -> newState step and reproduce precisely how a bug happened.

On a screen that shows a title and one button, the same ceremony is pure overhead. Plain MVVM with one or two ViewModel functions is simpler and just as correct there. Knowing which of those two screens you're looking at, and reaching for the lighter tool when that's what the screen needs, is the judgment call worth demonstrating.

Put the pieces back together and MVI is one loop: a single immutable UiState renders the View, every action becomes an Intent funneled through one entry point, and a pure reduce(state, intent) -> newState is the only thing allowed to produce the next snapshot. Side effects and one-off events live at the edges of that loop, never inside the reducer, so the loop itself stays deterministic.

If an interviewer asks you to defend it, the honest answer is a tradeoff, not a sales pitch: MVI costs more boilerplate than MVVM up front, a state class, a sealed intent hierarchy, a reducer, effect plumbing, but in exchange every transition is explicit, testable in isolation, and replayable when something breaks. That trade is worth making on a screen complex enough that inconsistent partial state is a real risk, and not worth making on one that isn't. Being able to say which is which, for the screen actually in front of you, is what the question is really testing.

Back to MVI Pattern