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-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.
| Component | Role in MVI | Example |
|---|---|---|
| Intent | User or system intention | LoadUser, Refresh, SubmitForm |
| State | Immutable screen state | sealed class UserState |
| Reducer | Pure function: State + Intent → State | fun reduce(state, intent) -> state |
| Middleware | Side effects handling | Network 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 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.
// 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 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).
// 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 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.
| Criterion | MVVM | MVI |
|---|---|---|
| State | Multiple LiveData/StateFlow | Single sealed class State |
| Data flow | Bidirectional (View → ViewModel, LiveData → View) | Unidirectional (Intent → Reducer → State → View) |
| Side effects | Directly in ViewModel | Via Middleware/EffectHandler |
| Testing | Unit tests for ViewModel | Unit tests for Reducer + Middleware |
| Boilerplate code | Minimal | Reducer + 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.
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
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.
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.
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.
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.
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
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.
Read also