Kotlin MVI vs MVVM voor formulierintensieve Android-apps: UI-staten
Kotlin MVI vs MVVM voor formulierintensieve Android-apps, uitgelegd met praktische manieren om validatie, optimistische UI, foutstatussen en offline concepten te modelleren.

Waarom formulierintensieve Android-apps snel rommelig worden
Formulieren voelen traag of fragiel aan omdat gebruikers constant wachten op kleine beslissingen die je code moet nemen: is dit veld geldig, is de save gelukt, moeten we een fout tonen, en wat gebeurt er als het netwerk wegvalt.
Formulieren leggen ook veruit de eerste state-bugs bloot omdat ze meerdere soorten state tegelijk mengen: UI-state (wat zichtbaar is), invoer-state (wat de gebruiker typte), server-state (wat is opgeslagen) en tijdelijke state (wat in uitvoering is). Als die uit synch lopen, voelt de app ‘willekeurig’ aan: knoppen schakelen op het verkeerde moment uit, oude fouten blijven hangen of het scherm reset na rotatie.
De meeste problemen clusteren in vier gebieden: validatie (vooral cross-field regels), optimistische UI (snelle feedback terwijl werk nog loopt), foutafhandeling (duidelijke, herstelbare fouten) en offline concepten (verlies van onafgemaakte werken voorkomen).
Goede formulier-UX volgt een paar eenvoudige regels:
- Validatie moet behulpzaam en dicht bij het veld zijn. Blokkeer typen niet. Wees streng wanneer het ertoe doet, meestal bij submit.
- Optimistische UI moet de actie van de gebruiker meteen weerspiegelen, maar ook een helder rollback-pad hebben als de server het afwijst.
- Fouten moeten specifiek, actiegericht zijn en nooit de invoer van de gebruiker wissen.
- Concepten moeten restarts, onderbrekingen en slechte verbindingen overleven.
Daarom worden architectuurdebatten intens voor formulieren. Het patroon dat je kiest bepaalt hoe voorspelbaar die staten aanvoelen onder druk.
Korte opfrisser: MVVM en MVI in gewone woorden
Het echte verschil tussen MVVM en MVI is hoe veranderingen door een scherm stromen.
MVVM (Model View ViewModel) ziet er meestal zo uit: de ViewModel houdt schermdata bij, stelt die beschikbaar aan de UI (vaak via StateFlow of LiveData) en biedt methoden zoals save, validate of load. De UI roept ViewModel-functies aan wanneer de gebruiker interacteert.
MVI (Model View Intent) ziet er meestal zo uit: de UI stuurt events (intents), een reducer verwerkt ze en het scherm rendert vanuit één state-object dat alles bevat wat de UI nu nodig heeft. Side-effects (netwerk, database) worden op een gecontroleerde manier gestart en rapporteren resultaten terug als events.
Een simpele manier om de mindset te onthouden:
- MVVM vraagt: “Welke data moet de ViewModel blootstellen en welke methoden moet hij aanbieden?”
- MVI vraagt: “Welke events kunnen er gebeuren en hoe transformeren die één state?”
Beide patronen werken prima voor eenvoudige schermen. Zodra je cross-field validatie, autosave, retries en offline concepten toevoegt, heb je strengere regels nodig over wie state mag veranderen en wanneer. MVI dwingt die regels standaard af. MVVM kan nog steeds goed werken, maar vraagt discipline: consistente updatepaden en zorgvuldige behandeling van eenmalige UI-events (toasts, navigatie).
Hoe formulierstate te modelleren zonder verrassingen
De snelste manier om de controle te verliezen is formulierdata op te laten leven op te veel plekken: view bindings, meerdere flows en “nog één boolean”. Formulieren blijven voorspelbaar als er één bron van waarheid is.
Een praktisch FormState-ontwerp
Streef naar één FormState die ruwe invoer plus een paar afgeleide flags bevat waar je op kunt vertrouwen. Houd het saai en compleet, ook al voelt het wat groter aan.
data class FormState(
val fields: Fields,
val fieldErrors: Map\u003cFieldId, String\u003e = emptyMap(),
val formError: String? = null,
val isDirty: Boolean = false,
val isValid: Boolean = false,
val submitStatus: SubmitStatus = SubmitStatus.Idle,
val draftStatus: DraftStatus = DraftStatus.NotSaved
)
sealed class SubmitStatus { object Idle; object Saving; object Saved; data class Failed(val msg: String) }
sealed class DraftStatus { object NotSaved; object Saving; object Saved }
Dit houdt veldniveau-validatie (per invoer) gescheiden van formulier-brede problemen (zoals “totaal moet \u003e 0”). Afgeleide flags zoals isDirty en isValid moeten op één plaats worden berekend, niet opnieuw geïmplementeerd in de UI.
Een helder mentaal model is: fields (wat de gebruiker typte), validatie (wat fout is), status (wat de app doet), dirtiness (wat veranderde sinds de laatste save) en concepten (of er een offline kopie bestaat).
Waar eenmalige effecten horen
Formulieren triggeren ook eenmalige events: snackbars, navigatie, ‘opgeslagen’ banners. Stop deze niet in FormState, anders herhalen ze bij rotatie of wanneer de UI zich opnieuw abonneert.
In MVVM emit je effecten via een aparte channel (bijvoorbeeld een SharedFlow). In MVI modelleer je ze als Effects (of Events) die de UI één keer consumeert. Deze scheiding voorkomt “fantoom”fouten en dubbele succesmails.
Validatiestroom in MVVM vs MVI
Validatie is waar formulier-schermen fragiel beginnen aan te voelen. De kernkeuze is waar regels leven en hoe resultaten terugkomen naar de UI.
Eenvoudige, synchrone regels (verplichte velden, min-lengte, nummerbereiken) moeten in de ViewModel of domeinlaag draaien, niet in de UI. Dat houdt regels testbaar en consistent.
Asynchrone regels (zoals “is dit e-mailadres al in gebruik?”) zijn lastiger. Je moet loading, verouderde resultaten en het ‘gebruiker typt opnieuw’-geval afhandelen.
In MVVM wordt validatie vaak een mix van state en hulpfuncties: de UI stuurt wijzigingen (tekstupdates, focusveranderingen, submit-clicks) naar de ViewModel; de ViewModel werkt een StateFlow/LiveData bij en stelt per-veld fouten en een afgeleide "canSubmit" bloot. Async checks starten meestal een job, zetten een loading-flag en werken een fout bij als ze klaar zijn.
In MVI is validatie doorgaans explicieter. Een praktische taakverdeling is:
- De reducer voert synchrone validatie uit en werkt veldfouten onmiddellijk bij.
- Een effect draait asynchrone validatie en dispatcht een result-intent.
- De reducer past dat resultaat alleen toe als het nog steeds overeenkomt met de nieuwste invoer.
Die laatste stap is belangrijk. Als de gebruiker een nieuw e-mailadres typt terwijl de check voor “uniek e-mail” nog loopt, mogen oude resultaten de huidige invoer niet overschrijven. MVI maakt dat vaak makkelijker om in te coderen omdat je de laatst-gecontroleerde waarde in state kunt opslaan en verouderde responses kunt negeren.
Optimistische UI en asynchrone saves
Optimistische UI betekent dat het scherm zich gedraagt alsof de save gelukt is voordat het netantwoord binnenkomt. In een formulier betekent dat vaak dat de Save-knop naar “Saving...” schakelt, een klein “Saved”-indicator verschijnt wanneer het klaar is, en velden bruikbaar blijven (of bewust vergrendeld worden) terwijl het verzoek loopt.
In MVVM wordt dit vaak geïmplementeerd door flags te toggelen zoals isSaving, lastSavedAt en saveError. Het risico is drift: overlappende saves kunnen die flags inconsistent achterlaten. In MVI werkt een reducer één state-object bij, waardoor “Saving” en “Disabled” minder snel tegenstrijdig zijn.
Om dubbele submits en racecondities te vermijden, behandel elke save als een geïdentificeerd event. Als de gebruiker twee keer op Save tikt of tijdens een save bewerkt, heb je een regel nodig welke response wint. Een paar voorzorgen werken in beide patronen: disable Save tijdens het opslaan (of debounce clicks), voeg een requestId (of versie) toe aan elke save en negeer verouderde responses, annuleer lopend werk wanneer de gebruiker vertrekt, en definieer wat edits tijdens save betekenen (een nieuwe save in de wachtrij, of het formulier weer dirty markeren).
Gedeeltelijk succes komt ook vaak voor: de server accepteert sommige velden maar wijst andere af. Modelleer dat expliciet. Houd per-veld fouten (en, indien nodig, per-veld sync-status) zodat je “Opgeslagen” overall kunt tonen terwijl een specifiek veld nog aandacht nodig heeft.
Fouttoestanden waarvan gebruikers kunnen herstellen
Formulierschermen falen op meer manieren dan “er ging iets mis”. Als elke fout een generieke toast wordt, typen gebruikers opnieuw, verliezen ze vertrouwen en haken ze af. Het doel is altijd hetzelfde: houd invoer veilig, toon een duidelijke oplossing en maak retry normaal.
Het helpt om fouten te scheiden op basis van waar ze horen. Een verkeerd e-mailformaat is niet hetzelfde als een serverstoring.
Veldfouten moeten inline zijn en aan één invoer gekoppeld. Formulierfouten horen bij de submit-actie en leggen uit wat blokkades zijn. Netwerkfouten moeten retry aanbieden en het formulier bewerkbaar houden. Permissie- of auth-fouten moeten de gebruiker leiden naar her-authenticatie terwijl een concept behouden blijft.
Een kernregel voor herstel: wis nooit gebruikersinvoer bij een fout. Als de save faalt, houd de huidige waarden in geheugen en op schijf. Retry moet hetzelfde payload opnieuw versturen tenzij de gebruiker iets wijzigt.
Waar patronen verschillen is hoe serverfouten terug worden gemapt naar UI-state. In MVVM is het makkelijk meerdere flows of velden bij te werken en per ongeluk inconsistenties te creëren. In MVI pas je meestal de serverresponse in één reducerstap toe die fieldErrors en formError samen bijwerkt.
Bepaal bovendien wat state is versus een eenmalig effect. Inline-fouten en “inzending mislukt” horen in state (ze moeten rotatie overleven). Eenmalige acties zoals een snackbar, vibratie of navigatie horen bij effecten.
Offline concepten en het herstellen van onafgemaakte formulieren
Een formulierintensieve app voelt “offline” aan, zelfs als het netwerk goed is. Gebruikers wisselen van app, het OS tikt je proces weg of ze verliezen signaal halverwege. Concepten voorkomen dat ze opnieuw moeten beginnen.
Definieer eerst wat een concept betekent. Alleen het “schone” model opslaan is vaak niet genoeg. Je wilt meestal het scherm precies herstellen zoals het eruitzag, inclusief half-getypte velden.
Wat het waard is om te persistente is voornamelijk ruwe gebruikersinvoer (strings zoals getypt, geselecteerde ID's, attachment-URIs), plus genoeg metadata om later veilig te mergen: een laatste bekende server-snapshot en een versiemarker (updatedAt, ETag of een simpele increment). Validatie kan bij herstel opnieuw worden berekend.
De opslagkeuze hangt af van gevoeligheid en grootte. Kleine concepten kunnen in preferences, maar multi-step formulieren en attachments zijn veiliger in een lokale database. Als het concept persoonlijke data bevat, gebruik versleutelde opslag.
De grootste architectuurvraag is waar de bron van waarheid leeft. In MVVM persistente teams vaak vanuit de ViewModel wanneer velden veranderen. In MVI kan persistente logica eenvoudiger zijn omdat je na elke reducer-update één coherent state-object (of een afgeleid Draft-object) opslaat.
De timing van autosave doet ertoe. Opslaan bij elke toetsaanslag is luidruchtig; een korte debounce (bijvoorbeeld 300–800 ms) plus een save bij stapwissel werkt goed.
Wanneer de gebruiker weer online komt, heb je merge-regels nodig. Een praktische benadering is: als de serverversie niet is veranderd, pas het concept toe en submit. Als die wel veranderd is, toon een duidelijke keuze: behoud mijn concept of laad serverdata opnieuw.
Stap-voor-stap: implementeer een betrouwbaar formulier met beide patronen
Betrouwbare formulieren beginnen met duidelijke regels, niet met UI-code. Elke gebruikersactie moet leiden tot een voorspelbare state, en elk asynchroon resultaat moet één duidelijke plek hebben om te landen.
Schrijf de acties op waarop je scherm moet reageren: typen, focusverlies, submit, retry en stapnavigatie. In MVVM worden dit ViewModel-methoden en state-updates. In MVI worden het expliciete intents.
Bouw dan in kleine stappen:
- Definieer events voor de volledige lifecycle: edit, blur, submit, save success/failure, retry, restore draft.
- Ontwerp één state-object: veldwaarden, per-veld fouten, algemene formulierstatus en “onopgeslagen wijzigingen”.
- Voeg validatie toe: lichte checks tijdens het bewerken, zwaardere checks bij submit.
- Voeg optimistische save-regels toe: wat verandert meteen en wat triggert rollback.
- Voeg concepten toe: autosave met debounce, herstellen bij openen en toon een klein “concept hersteld”-indicator zodat gebruikers vertrouwen krijgen in wat ze zien.
Behandel fouten als onderdeel van de ervaring. Houd invoer, markeer alleen wat moet worden aangepast en bied één duidelijk volgende actie (bewerk, retry of behoud concept).
Als je complexe formulierstaten wilt prototypen voordat je Android-UI schrijft, kan een no-code platform zoals AppMaster (appmaster.io) handig zijn om de workflow eerst te valideren. Daarna kun je dezelfde regels in MVVM of MVI implementeren met minder verrassingen.
Voorbeeldscenario: multi-step onkostendeclaratieformulier
Stel je een 4-staps onkostendeclaratie voor: details (datum, categorie, bedrag), bon-upload, notities en dan review & submit. Na submit toont het goedkeuringsstatussen zoals Draft, Submitted, Rejected, Approved. De lastige punten zijn validatie, saves die kunnen falen en het behouden van een concept als de telefoon offline raakt.
In MVVM houd je meestal een FormUiState in de ViewModel (vaak een StateFlow). Elke veldwijziging roept een ViewModel-functie zoals onAmountChanged() of onReceiptSelected() aan. Validatie draait bij wijziging, stapnavigatie of submit. Een gebruikelijke structuur is ruwe invoer plus veldfouten, met afgeleide vlaggen die bepalen of Next/Submit ingeschakeld is.
In MVI wordt dezelfde flow expliciet: de UI stuurt intents zoals AmountChanged, NextClicked, SubmitClicked en RetrySave. Een reducer retourneert een nieuwe state. Side-effects (uploaden van bon, API-aanroep, tonen van snackbar) draaien buiten de reducer en voeden resultaten terug als events.
In de praktijk maakt MVVM het makkelijk om snel functies toe te voegen en een flow bij te werken. MVI maakt het lastiger om per ongeluk een staatstransitie over te slaan omdat elke verandering door de reducer moet gaan.
Veelvoorkomende fouten en valkuilen
De meeste formulierbugs komen door onduidelijke regels over wie de waarheid bezit, wanneer validatie draait en wat er gebeurt wanneer asynchrone resultaten laat binnenkomen.
De meest voorkomende fout is het mengen van bronnen van waarheid. Als een tekstveld soms uit een widget leest, soms uit ViewModel-state en soms uit een hersteld concept, krijg je willekeurige resets en meldingen als “mijn invoer is verdwenen”. Kies één canonieke state voor het scherm en leid alles daaruit af (domeinmodel, cache-rijen, API-payloads).
Een andere makkelijke valkuil is het verwarren van state met events. Een toast, navigatie of “Saved!” banner is een éénmalig event. Een foutmelding die zichtbaar moet blijven tot de gebruiker iets aanpast is state. Het mengen hiervan veroorzaakt dubbele effecten bij rotatie of ontbrekende feedback.
Twee correctheidsproblemen komen vaak voor:
- Over-validatie bij elke toetsaanslag, vooral voor dure checks. Debounce, valideer op blur of valideer alleen aangeraakte velden.
- Het negeren van asynchrone resultaten die out-of-order komen. Als de gebruiker twee keer opslaat of bewerkt na het opslaan, kunnen oudere responses nieuwere invoer overschrijven tenzij je request-ids gebruikt (of “laatste alleen” logica).
Ten slotte zijn concepten niet “gewoon JSON opslaan”. Zonder versionering kunnen app-updates restores kapotmaken. Voeg een eenvoudige schema-versie en een migratieplan toe, ook al is dat “drop en begin opnieuw” voor zeer oude concepten.
Snelle checklist voordat je release
Voordat je MVVM vs MVI discussieert, zorg dat je formulier één duidelijke bron van waarheid heeft. Als een waarde op het scherm kan veranderen, hoort die in state, niet in een view-widget of verborgen vlag.
Een praktische pre-ship check:
- State bevat invoer, veldfouten, save-status (idle/saving/saved/failed) en draft/queue-status zodat de UI nooit hoeft te raden.
- Validatieregels zijn puur en testbaar zonder UI.
- Optimistische UI heeft een rollback-pad voor serverafwijzing.
- Fouten wissen nooit gebruikersinvoer.
- Draft-herstel is voorspelbaar: ofwel een duidelijke auto-restore-banner of een expliciete “Restore draft”-actie.
Een extra test die echte bugs vangt: zet vliegtuigmodus aan halverwege een save, zet hem weer uit en probeer twee keer opnieuw. De tweede retry mag geen duplicaat maken. Gebruik een request-id, idempotency key of een lokale “pending save”-marker zodat retries veilig zijn.
Als je antwoorden vaag zijn, verscherp eerst het statemodel en kies dan het patroon dat die regels het makkelijkst afdwingt.
Volgende stappen: een pad kiezen en sneller bouwen
Begin met één vraag: hoe kostbaar is het als je formulier in een raar half-geüpdatet state terechtkomt? Als de kosten laag zijn, houd het simpel.
MVVM past goed wanneer het scherm eenvoudig is, de state vooral “velden + fouten” is en je team al vertrouwd is met ViewModel + LiveData/StateFlow.
MVI past beter wanneer je strikte, voorspelbare state-transities nodig hebt, veel asynchrone events (autosave, retry, sync) of wanneer bugs duur zijn (betalingen, compliance, kritieke workflows).
Welk pad je ook kiest, de tests met de hoogste opbrengst voor formulieren raken vaak de UI niet: edgecases voor validatie, statetransities (edit, submit, success, failure, retry), optimistische save-rollback en draft-herstel plus conflictgedrag.
Als je ook backend, adminschermen en API's naast je mobiele app nodig hebt, kan AppMaster (appmaster.io) productieklare backend, web en native mobiele apps genereren vanuit één model, wat helpt om validatie- en workflowregels consistent te houden over oppervlakken heen.
FAQ
Kies MVVM wanneer je formulierflow grotendeels lineair is en je team al goede conventies heeft voor StateFlow/LiveData, eenmalige events en cancelatie. Kies MVI als je veel overlappende asynchrone taken verwacht (autosave, retries, uploads) en je strengere regels wilt zodat statuswijzigingen niet “stiekem” uit meerdere plekken kunnen voorkomen.
Begin met één enkel schermstatusobject (bijvoorbeeld FormState) dat ruwe veldwaarden, veldfouten, een formulierfout en duidelijke statussen zoals Saving of Failed bevat. Houd afgeleide vlaggen zoals isValid en canSubmit op één plek berekend zodat de UI alleen rendert en niet opnieuw logica beslist.
Voer lichte, goedkope checks uit tijdens het typen (verplicht, bereik, basale formaatcontroles) en voer strikte controles uit bij submit. Houd validatiecode buiten de UI zodat je het kunt testen en sla fouten op in state zodat ze rotatie en procesherstel overleven.
Behandel asynchrone validatie als “laatste invoer wint.” Sla de waarde op die je gevalideerd hebt (of een request-/versie-id) en negeer resultaten die niet overeenkomen met de huidige staat. Dit voorkomt dat verouderde antwoorden nieuw typwerk overschrijven, een veelvoorkomende bron van willekeurige foutmeldingen.
Werk de UI direct bij om de actie te weerspiegelen (bijvoorbeeld Saving… tonen en de invoer zichtbaar houden), maar zorg altijd voor een rollback-pad als de server de save afwijst. Gebruik een request-id/versie, zet de knop Save uit of debounce hem, en definieer wat bewerkingen tijdens het opslaan betekenen (velden vergrendelen, een nieuwe save in de wachtrij plaatsen of opnieuw dirty markeren).
Wis nooit de gebruikersinvoer bij een fout. Plaats veldspecifieke problemen inline bij de relevante velden, houd formulierblokkades dicht bij de submit-actie en maak netwerkfouten herstelbaar met een retry die hetzelfde payload opnieuw verstuurt tenzij de gebruiker iets verandert.
Houd eenmalige effecten buiten je persistente state. In MVVM zend je ze via een aparte stroom (zoals een SharedFlow), en in MVI modelleer je ze als Effects die de UI één keer consumeert. Dit voorkomt dubbele snackbars of herhaalde navigatie na rotatie of her-abonneren.
Sla voornamelijk ruwe gebruikersinvoer op (zoals getypte strings, geselecteerde ID's, attachment-URIs) plus minimale metadata om veilig later te herstellen en te mergen, zoals een laatste bekende serverversiemarker. Herbereken validatie bij herstel in plaats van die op te slaan, en voeg een eenvoudige schema-versie toe zodat app-updates restores niet breken.
Gebruik een korte debounce (ongeveer enkele honderden milliseconden) plus saves bij stapwissel of wanneer de gebruiker de app naar de achtergrond brengt. Opslaan bij elke toetsaanslag is luidruchtig en kan extra concurrentie veroorzaken; alleen opslaan bij afsluiten loopt risico op verlies van werk bij procesdood of onderbrekingen.
Houd een versiemarker (zoals updatedAt, een ETag of een lokale increment) bij voor zowel de server-snapshot als het concept. Als de serverversie niet is veranderd, pas het concept toe en submit; als die wel is veranderd, toon een duidelijke keuze: behoud mijn concept of herlaad serverdata, in plaats van stilletjes één kant te overschrijven.


