MVI (Model-View-Intent) — een reactief architectuurpatroon gebaseerd op unidirectionele gegevensstroom en onveranderlijke toestand. In tegenstelling tot MVVM, waar ViewModel meerdere StateFlows kan hebben, definieert MVI één enkele toestand (State), onveranderlijke intenties (Intent) en een pure reducer-functie (Reducer). MVI garandeert voorspelbaarheid van de schermtoestand op elk moment. Het patroon is gepopulariseerd in de Android-gemeenschap door de bibliotheken Mosby en Orbit. Meer — in MVIKotlin van Arkadii Ivanov.
Belangrijkste
MVI (Model-View-Intent) — een reactief architectuurpatroon gebouwd op de principes van Redux en Cycle.js. Model — de onveranderlijke schermtoestand, Intent — de intentie van de gebruiker of het systeem, View — abonneren op de toestand en Intent verzenden. Gegevens bewegen in een cyclus: de gebruiker interageert met View → View creëert een Intent → Intent wordt verwerkt door Reducer → Reducer creëert een nieuwe toestand → View ontvangt de nieuwe toestand en wordt opnieuw getekend.
Het belangrijkste verschil tussen MVI en MVVM — één enkele bron van waarheid (Single Source of Truth). In MVVM kan ViewModel meerdere LiveData/StateFlows hebben (userState, loadingState, errorState), wat leidt tot inconsistentie: loading=true en user=null tegelijkertijd. In MVI bestaat er precies één sealed class/interface State die de volledige schermtoestand beschrijft. Op elk moment is de schermtoestand eenduidig bepaald — het is onmogelijk om loading=true te krijgen terwijl gegevens al geladen zijn. Bij IT Sectr passen we MVI toe voor schermen met complexe logica — bestelformulieren, meerstapsregistraties, financiële schermen — waar voorspelbaarheid van de toestand kritisch is.
| Component | Rol in MVI | Voorbeeld |
|---|---|---|
| Intent | Intentie van gebruiker of systeem | LoadUser, Refresh, SubmitForm |
| State | Onveranderlijke schermtoestand | sealed class UserState |
| Reducer | Pure functie: State + Intent → State | fun reduce(state, intent) -> state |
| Middleware | Verwerking van bijwerkingen | Netwerkverzoek, schrijven naar DB |
MVI-cyclus bestaat uit vijf stappen: 1) View verzendt een Intent (bijv. LoadUser(42)); 2) Middleware (EffectHandler) voert de bijwerking uit — netwerkverzoek; 3) Het resultaat keert terug als een nieuwe Intent in het systeem; 4) Reducer ontvangt de huidige toestand en Intent, creëert een nieuwe toestand; 5) View ontvangt de nieuwe toestand en wordt opnieuw getekend. Elke stap is voorspelbaar en wordt geïsoleerd getest.
MVI op Android wordt geïmplementeerd via sealed-klassen voor Intent en State, ViewModel met MVI-logica en Jetpack Compose voor reactieve weergave. ViewModel ontvangt Intent van View, delegeert bijwerkingen naar Middleware, voert Reducer uit en publiceert de nieuwe toestand via StateFlow. Jetpack Compose hertekent de UI bij verandering van state — ideaal voor de MVI-cyclus.
// Intent — gebruikersintenties
sealed interface UserIntent {
data class LoadUser(val userId: Int) : UserIntent
data object Refresh : UserIntent
}
// State — één enkele schermtoestand
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 functie
object UserReducer {
fun reduce(state: UserState, intent: UserIntent): UserState = when (intent) {
is UserIntent.LoadUser -> UserState.Loading
is UserIntent.Refresh -> UserState.Loading
}
}
// ViewModel met 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(/* vorige 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 verzendt 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 en bijwerkingen — in MVI kan de pure Reducer geen netwerkverzoeken uitvoeren. Middleware (ook EffectHandler of Bootstrapper) verwerkt Intent, voert de bijwerking uit en emitteert een nieuwe Intent terug in de cyclus. De bibliotheken Orbit MVI en MVIKotlin bieden ingebouwde ondersteuning voor Middleware met testbare effecten. Zonder Middleware degradeert MVI naar MVVM met extra Intent- en State-structuur.
MVIKotlin van Arkadii Ivanov — de populairste MVI-bibliotheek voor Kotlin Multiplatform. Ondersteunt Android, iOS, web en JVM. Biedt componenten: Store (ViewModel), Bootstrapper (initiële effecten), Reducer, Middleware. In oktober 2025 heeft de bibliotheek 2,5K sterren op GitHub verzameld en wordt gebruikt in commerciële projecten, waaronder applicaties van grote Russische banken. Bij IT Sectr gebruiken we MVIKotlin voor cross-platform KMP-projecten met gedeelde bedrijfslogica.
MVI op iOS wordt geïmplementeerd zonder Combine-ViewModel, via de Intent → State cyclus. View verzendt Intent via een afsluiting (closure), Reducer — pure functie, State — struct met onveranderlijke velden. SwiftUI hertekent View bij verandering van State, wat perfect past in de MVI-cyclus zonder extra @Published-eigenschappen. MVI op iOS is vooral populair in de gemeenschap van SwiftUI-ontwikkelaars die zijn overgestapt van Redux (JavaScript).
// State — onveranderlijke structuur
struct UserState: Equatable {
var user: User?
var isLoading = false
var errorMessage: String?
}
// Intent — enum met intenties
enum UserIntent {
case loadUser(id: Int)
case refresh
case userLoaded(User)
case loadFailed(Error)
}
// Reducer — pure functie
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 — bezit de toestand en beheert effecten
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 werkt de toestand bij
state = userReducer(state: state, intent: intent)
// 2. Side effects (indien nodig)
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) — de populairste MVI-implementatie voor iOS van Point-Free, gebouwd op SwiftUI en Combine. TCA biedt Store, Reducer, Effect en Environment. In oktober 2025 overschrijdt het aantal sterren op GitHub de 13K — dit is de de facto standaard voor MVI op iOS. TCA wordt gebruikt in de apps van Starbucks, Airbnb (gedeeltelijk) en veel indie-projecten. In tegenstelling tot handgeschreven MVI lost TCA problemen van testen, navigatie en bijwerkingen uit de doos op.
MVI versus MVVM op iOS — TCA/MVI biedt voorspelbaarheid van de toestand, maar vereist meer sjablooncode (Reducer, State, Action). MVVM met @Published is eenvoudiger voor simpele schermen. Wij bij IT Sectr gebruiken MVVM voor 80% van de schermen en MVI (TCA) voor 20% complexe — financiële transacties, meerstapsformulieren, drag-and-drop interfaces, waar een toestandsfout de gebruiker geld kan kosten.
MVI en MVVM lossen dezelfde taak op — de organisatie van de Presentation-laag — maar met verschillende benaderingen van toestandsbeheer. MVVM staat meerdere reactieve bronnen toe (LiveData, @Published), wat kan leiden tot inconsistentie. MVI garandeert precies één toestand op elk moment, wat het rigoureuzer en voorspelbaarder maakt, maar de codeomvang vergroot.
| Criterium | MVVM | MVI |
|---|---|---|
| Toestand | Meerdere LiveData/StateFlows | Eén sealed class State |
| Gegevensstroom | Bidirectioneel (View → ViewModel, LiveData → View) | Unidirectioneel (Intent → Reducer → State → View) |
| Bijwerkingen | Direct in ViewModel | Via Middleware/EffectHandler |
| Testen | Unit tests van ViewModel | Unit tests van Reducer + Middleware |
| Sjablooncode | Minimaal | Reducer + State + Intent + Middleware |
Wanneer MVI kiezen — schermen waar de toestand strikt deterministisch moet zijn: financiële operaties, winkelwagen van een webshop, meerstapsformulieren met validatie bij elke stap. In deze scenario's wegen de kosten van een toestandsfout (bijvoorbeeld het weergeven van het winkelwagenbedrag zonder één product door een raceconditie van twee LiveData's) zwaarder dan de kosten van extra code. In MVVM vertrouwt u op de discipline van het team, in MVI — op de architectuur.
Wanneer MVVM voldoende is — 80% van de standaardschermen: gebruikerslijst, profiel, instellingen, nieuwsfeed. Hier is een enkele toestand overbodig en vertraagt de extra MVI-structuur de ontwikkeling. Bij IT Sectr is de regel: als een scherm 3+ mogelijke toestanden met overgangen heeft (laden → gegevens → fout → opnieuw → laden → gegevens) — MVI. Als een scherm 1-2 asynchrone operaties heeft — MVVM.
Sealed State — beste praktijk van MVI. De toestand wordt gedefinieerd als een sealed class/interface met varianten Loading, Success(data), Error(message). Dit garandeert dat View niet in een inconsistente toestand terechtkomt — gegevens kunnen niet worden weergegeven bij loading=true, omdat Loading en Success verschillende klassen zijn. Alle gegevens met betrekking tot de toestand bevinden zich binnen de sealed-variant: Success bevat de gebruiker, Error — het foutbericht.
Reducer moet een pure functie blijven — zonder API-aanroepen, DB, SharedPreferences. De pure functie ontvangt State en Intent, retourneert State. Bijwerkingen (netwerk, DB, navigatie, toasts) worden verwerkt in Middleware of in Store.dispatch na het aanroepen van Reducer. Als Reducer vervuild is met bijwerkingen, verliest MVI zijn testbaarheid en voorspelbaarheid — u krijgt MVVM met extra structuur zonder voordelen.
Typische fouten — het declareren van State als een data class met nullable velden in plaats van een sealed class: data class UserState(val user: User?, val isLoading: Boolean, val error: String?). Dit is het equivalent van MVVM, niet MVI — View moet combinaties van velden op geldigheid controleren. In de sealed-benadering zijn ongeldige combinaties (isLoading=true en user!=null) onmogelijk op typeniveau. De tweede fout — het plaatsen van bedrijfslogica in Intent (Intent.LoadUserBeforeXHours) in plaats van eenvoudige command Intent's te maken (Intent.LoadUser) en de bedrijfslogica in Middleware.
Veelgestelde vragen
MVI gebruikt één enkele onveranderlijke sealed State-klasse en unidirectionele gegevensstroom via Reducer. MVVM staat meerdere LiveData/StateFlows toe met bidirectionele binding. MVI garandeert consistentie van de toestand op typeniveau — het is onmogelijk om loading=true en user=null tegelijkertijd te krijgen. MVVM vertrouwt op de discipline van de ontwikkelaar.
Belangrijkste: MVIKotlin (Arkadii Ivanov, 2,5K sterren, Kotlin Multiplatform), Orbit MVI (BabyJ, 1,3K sterren), Mobius (Spotify, Kotlin/Java). MVIKotlin — de populairste voor Kotlin, Orbit — de eenvoudigste om te leren. Alle drie ondersteunen testbare Reducer en Middleware. Voor Jetpack Compose volstaat het om een simpele MVI zonder bibliotheek te schrijven via sealed State + Reducer.
Nee — sealed Intent + sealed State + ViewModel + StateFlow geven een werkende MVI zonder afhankelijkheden. Bibliotheken (MVIKotlin, Orbit, TCA) voegen Middleware, testen van bijwerkingen en integratie met DI toe. Voor eenvoudige projecten is het gewicht van de bibliotheek ongerechtvaardigd. Voor complexe projecten met 20+ schermen betaalt de bibliotheek zich terug door gestructureerde effectverwerking.
MVI is uitstekend geschikt voor iOS via TCA (The Composable Architecture) — de populairste architectuur van de SwiftUI-gemeenschap. TCA is eigenlijk MVI + Redux + Combine. Op iOS kan MVI ook zonder TCA worden geïmplementeerd via ObservableObject en een pure reducer-functie. SwiftUI met immutable State past perfect in de MVI-cyclus.
Reducer wordt getest met unit tests als een pure functie: een initiële State wordt gegeven, een Intent wordt verzonden, de uiteindelijke State wordt gecontroleerd. Middleware wordt getest met een mock-repository: er wordt gecontroleerd of getUser is aangeroepen na LoadUser. ViewModel-test: een Intent verzenden, StateFlow controleren. MVI is eenvoudiger te testen dan MVVM, omdat Reducer een pure functie is zonder verborgen afhankelijkheden.
Samenvatting
We ontwikkelen een mobiele applicatie turnkey
IT Sectr creëert sinds 2017 iOS- en Android-applicaties voor startups en bedrijven. We adviseren u en stellen de beste oplossing voor.
Lees ook