MVI — de essentie van het Model-View-Intent patroon in mobiele apps

Auteur: IT Sectr Gepubliceerd: 2026-02-16 Leestijd: 10 min

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 (toestand), View (weergave), Intent (gebruikersintentie) — reactieve cyclus
  • Unidirectional data flow — gegevens bewegen in één richting: Intent → Reducer → State → View
  • Immutable State — de schermtoestand — een onveranderlijk object dat bij elke wijziging opnieuw wordt aangemaakt
  • Reducer — een pure functie die de huidige toestand en Intent ontvangt en een nieuwe toestand retourneert
  • Side effects — bijwerkingen (netwerk, DB) worden apart van de Reducer verwerkt, via Middleware

Wat is MVI: de essentie van het Model-View-Intent patroon

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.

ComponentRol in MVIVoorbeeld
IntentIntentie van gebruiker of systeemLoadUser, Refresh, SubmitForm
StateOnveranderlijke schermtoestandsealed class UserState
ReducerPure functie: State + Intent → Statefun reduce(state, intent) -> state
MiddlewareVerwerking van bijwerkingenNetwerkverzoek, 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 in Android: Intent, Reducer, State in Kotlin

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.

kotlin
// 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 in iOS: unidirectionele stroom in Swift

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).

swift
// 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.

Vergelijking van MVI met MVVM: wanneer MVI kiezen

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.

CriteriumMVVMMVI
ToestandMeerdere LiveData/StateFlowsEén sealed class State
GegevensstroomBidirectioneel (View → ViewModel, LiveData → View)Unidirectioneel (Intent → Reducer → State → View)
BijwerkingenDirect in ViewModelVia Middleware/EffectHandler
TestenUnit tests van ViewModelUnit tests van Reducer + Middleware
SjablooncodeMinimaalReducer + 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.

Beste MVI-praktijken en typische fouten

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

Wat is het belangrijkste verschil tussen MVI en MVVM?

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.

Welke MVI-bibliotheken bestaan er voor Android?

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.

Is een aparte bibliotheek nodig voor MVI?

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.

Is MVI geschikt voor iOS of is het alleen een Android-patroon?

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.

Hoe test je MVI?

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

  • MVI (Model-View-Intent) — reactief patroon met unidirectionele stroom en één enkele toestand
  • Sealed State — garandeert consistentie op typeniveau, sluit ongeldige combinaties uit
  • Reducer — pure functie State + Intent → State, getest zonder mock-objecten
  • Middleware — aparte laag voor bijwerkingen (netwerk, DB, navigatie)
  • MVI vs MVVM — MVI is rigoureuzer en voorspelbaarder, MVVM eenvoudiger en sneller
  • Android — MVIKotlin of Orbit voor complexe schermen; MVVM voor eenvoudige
  • iOS — TCA (The Composable Architecture) — MVI-standaard op SwiftUI

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.

Bespreek het project

Lees ook