MVI — Understanding the Model-View-Intent Pattern in Mobile Apps

Author: IT Sectr Published: 2026-02-16 Reading time: 10 min

MVI (Model-View-Intent) is a reactive architectural pattern based on unidirectional data flow and immutable state. Unlike MVVM, where a ViewModel can have multiple StateFlows, MVI defines a single state (State), immutable intentions (Intent), and a pure reducer function (Reducer). MVI guarantees screen state predictability at any point in time. The pattern was popularized in the Android community by the Mosby and Orbit libraries. Learn more in MVIKotlin by Arkadii Ivanov.

Key Takeaways

  • MVI — Model (state), View (display), Intent (user intention) — a reactive cycle
  • Unidirectional data flow — data moves in one direction: Intent → Reducer → State → View
  • Immutable State — the screen state is an immutable object, recreated on every change
  • Reducer — a pure function that takes the current state and an Intent, returning a new state
  • Side effects — side effects (network, DB) are handled separately from the Reducer, via Middleware

What is MVI: The Essence of the Model-View-Intent Pattern

MVI (Model-View-Intent) is a reactive architectural pattern built on the principles of Redux and Cycle.js. Model is the immutable screen state, Intent is a user or system intention, View subscribes to state and sends Intents. Data flows in a cycle: the user interacts with the View → the View creates an Intent → the Intent is processed by the Reducer → the Reducer creates a new state → the View receives the new state and re-renders.

The main difference between MVI and MVVM is the Single Source of Truth. In MVVM, a ViewModel can have several LiveData/StateFlow (userState, loadingState, errorState), which leads to inconsistency: loading=true and user=null simultaneously. In MVI, there is exactly one sealed class/interface State that describes the entire screen state. At any point in time, the screen state is uniquely determined — it is impossible to have loading=true when data is already loaded. At IT Sectr, we use MVI for screens with complex logic — order forms, multi-step registrations, financial screens — where state predictability is critical.

ComponentRole in MVIExample
IntentUser or system intentionLoadUser, Refresh, SubmitForm
StateImmutable screen statesealed class UserState
ReducerPure function: State + Intent → Statefun reduce(state, intent) -> state
MiddlewareSide effects handlingNetwork request, DB write

The MVI cycle consists of five steps: 1) View sends an Intent (e.g., LoadUser(42)); 2) Middleware (EffectHandler) performs a side effect — a network request; 3) The result is returned as a new Intent into the system; 4) Reducer takes the current state and Intent, creates a new state; 5) View receives the new state and re-renders. Each step is predictable and testable in isolation.

MVI in Android: Intent, Reducer, State in Kotlin

MVI on Android is implemented using sealed classes for Intent and State, a ViewModel with MVI logic, and Jetpack Compose for reactive rendering. The ViewModel receives Intent from the View, delegates side effects to Middleware, runs the Reducer, and publishes the new state via StateFlow. Jetpack Compose re-renders the UI when the state changes — ideal for the MVI cycle.

kotlin
// Intent — user intentions
sealed interface UserIntent {
    data class LoadUser(val userId: Int) : UserIntent
    data object Refresh : UserIntent
}

// State — single screen state
sealed interface UserState {
    data object Idle : UserState
    data object Loading : UserState
    data class Success(val user: User) : UserState
    data class Error(val message: String) : UserState
}

// Reducer — pure function
object UserReducer {
    fun reduce(state: UserState, intent: UserIntent): UserState = when (intent) {
        is UserIntent.LoadUser -> UserState.Loading
        is UserIntent.Refresh -> UserState.Loading
    }
}

// ViewModel with MVI
class UserViewModel(
    private val repository: UserRepository
) : ViewModel() {

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

    fun process(intent: UserIntent) {
        val newState = UserReducer.reduce(_state.value, intent)
        _state.value = newState
        when (intent) {
            is UserIntent.LoadUser -> loadUser(intent.userId)
            is UserIntent.Refresh -> loadUser(/* previous ID */)
        }
    }

    private fun loadUser(userId: Int) {
        viewModelScope.launch {
            repository.getUser(userId)
                .onSuccess { user ->
                    _state.value = UserState.Success(user)
                }
                .onFailure { e ->
                    _state.value = UserState.Error(e.message ?: "Error")
                }
        }
    }
}

// View sends Intent
fun UserScreen(viewModel: UserViewModel) {
    val state by viewModel.state.collectAsState()
    LaunchedEffect(Unit) { viewModel.process(UserIntent.LoadUser(42)) }
    when (state) {
        is UserState.Loading -> CircularProgressIndicator()
        is UserState.Success -> UserCard((state as UserState.Success).user)
        is UserState.Error -> ErrorView((state as UserState.Error).message)
        is UserState.Idle -> Text("Press to load")
    }
}

Middleware and side effects — in MVI, a pure Reducer cannot perform network requests. Middleware (also EffectHandler or Bootstrapper) processes the Intent, performs the side effect, and emits a new Intent back into the cycle. The Orbit MVI and MVIKotlin libraries provide built-in Middleware support with testable effects. Without Middleware, MVI degenerates into MVVM with additional Intent and State structure.

MVIKotlin by Arkadii Ivanov is the most popular MVI library for Kotlin Multiplatform. It supports Android, iOS, web, and JVM. It provides components: Store (ViewModel), Bootstrapper (initial effects), Reducer, Middleware. As of October 2025, the library has 2.5K stars on GitHub and is used in commercial projects, including applications of major Russian banks. At IT Sectr, we use MVIKotlin for cross-platform KMP projects with shared business logic.

MVI in iOS: Unidirectional Flow in Swift

MVI on iOS is implemented without a Combine-ViewModel, through the Intent → State cycle. The View sends an Intent via a closure, the Reducer is a pure function, and State is a struct with immutable fields. SwiftUI re-renders the View when State changes, which fits perfectly into the MVI cycle without additional @Published properties. MVI on iOS is especially popular among SwiftUI developers who transitioned from Redux (JavaScript).

swift
// State — immutable structure
struct UserState: Equatable {
    var user: User?
    var isLoading = false
    var errorMessage: String?
}

// Intent — enum with intentions
enum UserIntent {
    case loadUser(id: Int)
    case refresh
    case userLoaded(User)
    case loadFailed(Error)
}

// Reducer — pure function
func userReducer(state: UserState, intent: UserIntent) -> UserState {
    var newState = state
    switch intent {
    case .loadUser, .refresh:
        newState.isLoading = true
        newState.errorMessage = nil
    case .userLoaded(let user):
        newState.isLoading = false
        newState.user = user
    case .loadFailed(let error):
        newState.isLoading = false
        newState.errorMessage = error.localizedDescription
    }
    return newState
}

// Store — owns state and manages effects
final class UserStore: ObservableObject {
    @Published private(set) var state = UserState()
    private let service: UserService

    init(service: UserService) {
        self.service = service
    }

    func dispatch(_ intent: UserIntent) {
        // 1. Reducer updates state
        state = userReducer(state: state, intent: intent)
        // 2. Side effects (if needed)
        switch intent {
        case .loadUser(let id), .refresh:
            service.fetchUser(id: id) { [weak self] result in
                switch result {
                case .success(let user):
                    self?.dispatch(.userLoaded(user))
                case .failure(let error):
                    self?.dispatch(.loadFailed(error))
                }
            }
        default: break
        }
    }
}

TCA (The Composable Architecture) is the most popular MVI implementation for iOS by Point-Free, built on SwiftUI and Combine. TCA provides Store, Reducer, Effect, and Environment. As of October 2025, its GitHub stars exceed 13K — it is the de facto standard for MVI on iOS. TCA is used in Starbucks apps, Airbnb (partially), and many indie projects. Unlike custom MVI, TCA addresses testing, navigation, and side effects out of the box.

MVI vs MVVM on iOS — TCA/MVI provides state predictability but requires more boilerplate code (Reducer, State, Action). MVVM with @Published is simpler for basic screens. At IT Sectr, we use MVVM for 80% of screens and MVI (TCA) for 20% of complex ones — financial transactions, multi-step forms, drag-and-drop interfaces, where a state error could cost the user money.

MVI vs MVVM: When to Choose MVI

MVI and MVVM solve the same problem — organizing the Presentation layer — but with different approaches to state management. MVVM allows multiple reactive sources (LiveData, @Published), which can lead to inconsistency. MVI guarantees exactly one state at any given moment, making it more strict and predictable, but it increases the amount of code.

CriterionMVVMMVI
StateMultiple LiveData/StateFlowSingle sealed class State
Data flowBidirectional (View → ViewModel, LiveData → View)Unidirectional (Intent → Reducer → State → View)
Side effectsDirectly in ViewModelVia Middleware/EffectHandler
TestingUnit tests for ViewModelUnit tests for Reducer + Middleware
Boilerplate codeMinimalReducer + State + Intent + Middleware

When to choose MVI — screens where state must be strictly deterministic: financial operations, shopping cart, multi-step forms with validation at each step. In these scenarios, the cost of a state error (e.g., showing the cart total missing one item due to a race condition between two LiveData) outweighs the cost of additional code. In MVVM, you rely on team discipline; in MVI, you rely on architecture.

When MVVM is sufficient — 80% of standard screens: user list, profile, settings, news feed. Here, a single state is overkill, and the additional MVI structure will slow down development. At IT Sectr, the rule is: if a screen has 3+ possible states with transitions (loading → data → error → retry → loading → data) — use MVI. If a screen has 1-2 async operations — use MVVM.

MVI Best Practices and Common Mistakes

Sealed State — best practice in MVI. The state is defined as a sealed class/interface with variants: Loading, Success(data), Error(message). This guarantees that the View will not end up in an inconsistent state — you cannot display data when loading=true because Loading and Success are different classes. All data related to the state resides inside the sealed variant: Success contains the user, Error contains the error message.

The Reducer must remain a pure function — without API, DB, or SharedPreferences calls. A pure function takes State and Intent and returns State. Side effects (network, DB, navigation, toasts) are handled in Middleware or in Store.dispatch after calling the Reducer. If the Reducer is polluted with side effects, MVI loses testability and predictability — you get MVVM with additional structure but no benefits.

Common mistakes — declaring State as a data class with nullable fields instead of a sealed class: data class UserState(val user: User?, val isLoading: Boolean, val error: String?). This is equivalent to MVVM, not MVI — the View must check field combinations for validity. In the sealed approach, invalid combinations (isLoading=true and user!=null) are impossible at the type level. The second mistake is placing business logic in Intent (Intent.LoadUserBeforeXHours) instead of creating simple command Intents (Intent.LoadUser) and putting business logic in Middleware.

Frequently Asked Questions

What is the main difference between MVI and MVVM?

MVI uses a single immutable sealed State class and unidirectional data flow through a Reducer. MVVM allows multiple LiveData/StateFlow with bidirectional binding. MVI guarantees state consistency at the type level — it is impossible to have loading=true and user=null simultaneously. MVVM relies on developer discipline.

What MVI libraries exist for Android?

Main ones: MVIKotlin (Arkadii Ivanov, 2.5K stars, Kotlin Multiplatform), Orbit MVI (BabyJ, 1.3K stars), Mobius (Spotify, Kotlin/Java). MVIKotlin is the most popular for Kotlin, Orbit is the easiest to learn. All three support testable Reducer and Middleware. For Jetpack Compose, you can write simple MVI without a library using sealed State + Reducer.

Is a separate library needed for MVI?

No — sealed Intent + sealed State + ViewModel + StateFlow gives you working MVI without dependencies. Libraries (MVIKotlin, Orbit, TCA) add Middleware, side effect testing, and DI integration. For simple projects, the library weight is unjustified. For complex projects with 20+ screens, the library pays off with structured effect handling.

Is MVI suitable for iOS or is it an Android-only pattern?

MVI works great for iOS through TCA (The Composable Architecture) — the most popular architecture in the SwiftUI community. TCA is essentially MVI + Redux + Combine. On iOS, you can implement MVI without TCA using ObservableObject and a pure reducer function. SwiftUI with immutable State fits perfectly into the MVI cycle.

How to test MVI?

The Reducer is tested with unit tests as a pure function: set the initial State, send an Intent, check the resulting State. Middleware is tested with a mock repository: verify that getUser was called after LoadUser. ViewModel test: send an Intent, check the StateFlow. MVI is easier to test than MVVM because the Reducer is a pure function with no hidden dependencies.

Summary

  • MVI (Model-View-Intent) — a reactive pattern with unidirectional flow and a single state
  • Sealed State — guarantees consistency at the type level, eliminating invalid combinations
  • Reducer — a pure function State + Intent → State, testable without mock objects
  • Middleware — a separate layer for side effects (network, DB, navigation)
  • MVI vs MVVM — MVI is stricter and more predictable, MVVM is simpler and faster
  • Android — MVIKotlin or Orbit for complex screens; MVVM for simple ones
  • iOS — TCA (The Composable Architecture) is the MVI standard on SwiftUI

We will develop a mobile application turnkey

IT Sectr creates iOS and Android applications for startups and businesses since 2017. We will advise you and propose the best solution.

Discuss the project

Read also