Skip to content
advanced Phase 6 · Architecture Patterns

MVI

Use Model-View-Intent for unidirectional data flow with sealed classes and state machines.

50m
3 problems
Topic Progress 0%

MVI as a State Machine

From MVVM to MVI

MVVM exposes mutable state freely — any method on the ViewModel can change any piece of state at any time. This flexibility becomes a liability in complex screens where state transitions must be predictable. MVI tightens this by treating the UI as a finite state machine: the screen is always in exactly one state, and only explicit user intents can cause transitions.

The Three Pillars

  • Intent: What the user wants to do — a tap, swipe, text input, or any interaction. In code, a sealed class where each subclass is one action.
  • State: An immutable data class representing the entire screen at a point in time. One state object, not multiple streams.
  • Effect: A one-time event — navigation, showing a snackbar, triggering a camera. Unlike State, Effects are consumed once and not replayed on recomposition.

The Event Loop

User Action → Intent → ViewModel processes → New State → View renders
                                  ↓
                              Effect → Navigation / Toast / Snackbar

This is a strict unidirectional cycle. The View never mutates state directly. It dispatches an Intent, and the ViewModel computes the next state deterministically.

Kotlin Code Example

// Intent — every possible user action
sealed interface LoginIntent {
    data class EmailChanged(val email: String) : LoginIntent
    data class PasswordChanged(val password: String) : LoginIntent
    object LoginClicked : LoginIntent
}

// State — single immutable snapshot of the screen
data class LoginUiState(
    val email: String = "",
    val password: String = "",
    val isLoading: Boolean = false,
    val errorMessage: String? = null
)

// Effect — one-time side effects
sealed interface LoginEffect {
    data class ShowToast(val message: String) : LoginEffect
    object NavigateToHome : LoginEffect
}

class LoginViewModel(private val authRepository: AuthRepository) : ViewModel() {
    private val _state = MutableStateFlow(LoginUiState())
    val state: StateFlow<LoginUiState> = _state.asStateFlow()

    private val _effect = Channel<LoginEffect>()
    val effect: Flow<LoginEffect> = _effect.receiveAsFlow()

    fun onIntent(intent: LoginIntent) {
        when (intent) {
            is LoginIntent.EmailChanged ->
                _state.update { it.copy(email = intent.email) }

            is LoginIntent.PasswordChanged ->
                _state.update { it.copy(password = intent.password) }

            is LoginIntent.LoginClicked -> login()
        }
    }

    private fun login() {
        viewModelScope.launch {
            _state.update { it.copy(isLoading = true, errorMessage = null) }
            try {
                authRepository.login(_state.value.email, _state.value.password)
                _effect.send(LoginEffect.NavigateToHome)
            } catch (e: Exception) {
                _state.update { it.copy(isLoading = false, errorMessage = e.message) }
            }
        }
    }
}

The ViewModel exposes a single onIntent entry point. Every user action goes through it. State is always consistent because there is exactly one state object.

Handling Side Effects and Navigation

The Side Effect Problem

State represents what the screen looks like. But some user actions produce outcomes that are not visual state — navigating to another screen, showing a toast, copying text to clipboard. If you embed these in state, they replay on every recomposition. A toast would fire again when the screen rotates.

Effects vs State

Property State Effect
Replay on recomposition Yes No
Represents screen appearance Yes No
Consumed once No Yes
Survives config change Yes No

Implementing Effects with Channel

class OrderViewModel(private val repository: OrderRepository) : ViewModel() {
    private val _state = MutableStateFlow(OrderUiState())
    val state: StateFlow<OrderUiState> = _state.asStateFlow()

    // Channel guarantees each effect is delivered exactly once
    private val _effects = Channel<OrderEffect>(Channel.BUFFERED)
    val effects: Flow<OrderEffect> = _effects.receiveAsFlow()

    fun onIntent(intent: OrderIntent) {
        when (intent) {
            is OrderIntent.PlaceOrder -> placeOrder()
            is OrderIntent.ApplyCoupon -> applyCoupon(intent.code)
        }
    }

    private fun placeOrder() {
        viewModelScope.launch {
            _state.update { it.copy(isPlacing = true) }
            when (val result = repository.placeOrder(_state.value.toOrder())) {
                is Result.Success -> {
                    _effects.send(OrderEffect.NavigateToConfirmation(result.orderId))
                    _effects.send(OrderEffect.ShowToast("Order placed!"))
                }
                is Result.Failure -> {
                    _state.update { it.copy(isPlacing = false, error = result.message) }
                }
            }
        }
    }
}

Collecting Effects in Compose

@Composable
fun OrderScreen(viewModel: OrderViewModel) {
    val state by viewModel.state.collectAsStateWithLifecycle()

    LaunchedEffect(Unit) {
        viewModel.effects.collect { effect ->
            when (effect) {
                is OrderEffect.NavigateToConfirmation ->
                    navController.navigate("confirmation/${effect.orderId}")
                is OrderEffect.ShowToast ->
                    Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
            }
        }
    }

    // Render based on state...
}

The LaunchedEffect scope ensures effects are collected once per composition and not lost across recompositions.

MVI vs MVVM — When to Choose Which

MVVM When

  • Simple screens with 2-3 state fields
  • Quick prototyping where strictness slows you down
  • Small teams familiar with LiveData patterns
  • CRUD screens where state transitions are trivial

MVI When

  • Complex screens with many interacting state fields
  • Multiple user actions that must produce deterministic state transitions
  • Shared ViewModel across multiple composables where state consistency is critical
  • Navigation-heavy apps where side effects must not replay
  • Teams that want to enforce a strict architecture contract

The Tradeoff

MVI adds boilerplate — every user action needs a sealed class entry, every state change goes through a reducer-like function. This ceremony pays off when the screen is complex enough that freeform state mutation becomes a liability.

// MVVM: Freeform state updates
fun onEmailChanged(email: String) { _email.value = email }
fun onPasswordChanged(pw: String) { _password.value = pw }
fun onLoginClicked() { login() }

// MVI: Single entry point, explicit intents
fun onIntent(intent: LoginIntent) {
    when (intent) {
        is LoginIntent.EmailChanged -> _state.update { it.copy(email = intent.email) }
        is LoginIntent.PasswordChanged -> _state.update { it.copy(password = intent.password) }
        is LoginIntent.LoginClicked -> login()
    }
}

For a login screen, MVVM is simpler. For a checkout flow with coupon application, payment method selection, address editing, and order placement, MVI's discipline prevents state from going out of sync.

Quiz

1. What are the three core components of MVI?

Question 1 options

2. Why are Effects separated from State in MVI?

Question 2 options

3. Which data structure guarantees each MVI Effect is consumed exactly once?

Question 3 options

4. In which scenario does MVI provide the most benefit over MVVM?

Question 4 options

Flashcards

Question

What is an Intent in MVI?

Answer

A sealed class representing every possible user action on a screen. Each subclass maps to one interaction (tap, text change, swipe).

Question

What is the difference between State and Effect in MVI?

Answer

State is an immutable snapshot of the screen that replays on recomposition. Effect is a one-time event (navigation, toast) consumed once and not replayed.

Question

Why is MVI called a 'state machine' pattern?

Answer

The screen is always in exactly one state. Only explicit Intents can trigger transitions to new states, making all UI behavior deterministic and traceable.

Question

When should you prefer MVVM over MVI?

Answer

For simple screens with few state fields, quick prototypes, or small teams where MVI's sealed class ceremony adds unnecessary boilerplate.

Revision Notes

Key Takeaways

  • 1. MVI models the UI as a finite state machine with deterministic transitions
  • 2. Intents, State, and Effects are the three pillars — each serves a distinct purpose
  • 3. Effects must use Channel to avoid replaying on recomposition
  • 4. MVI's ceremony pays off in complex screens; MVVM is simpler for basic screens

Interview Tips

  • Explain MVI as 'UI as a state machine' — the screen always has one canonical state
  • Draw the Intent → ViewModel → State/Effect → View cycle on a whiteboard
  • Discuss when MVI's boilerplate is worth it vs when MVVM is sufficient
  • Know the difference between Channel and StateFlow for delivering one-time effects

Cheat Sheet

MVI Cheat Sheet

Components:

  • Intent: Sealed class of user actions
  • State: Single immutable data class per screen
  • Effect: One-time events (navigation, toasts) via Channel

Flow: User → Intent → ViewModel → State/Effect → View

Key Rules:

  • One onIntent() entry point per ViewModel
  • State is always a single data class (no separate LiveData streams)
  • Effects use Channel to guarantee single consumption
  • ViewModel is a pure state reducer: Intent + Current State → New State

State vs Effect:

State Effect
Replays Yes No
Represents UI Yes No
Consumed once No Yes

Compose collection:

val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { viewModel.effects.collect { ... } }