A complete comparative analysis of Model-View-Intent (MVI) and Unidirectional Data Flow (UDF) architectures in modern Android and Kotlin Multiplatform (KMP) development.
- Foundational Theory: The Elm Architecture (TEA)
- The 8 Architectures Compared
- Master Comparison Matrix
- Deep Dives & Common Pitfalls
Almost all modern MVI and Redux architectures descend from The Elm Architecture (TEA), created in 2012 by Evan Czaplicki:
┌──────────────┐
│ VIEW │ ◄─── view(model)
└──────┬───────┘
│ User Dispatches Msg
▼
┌──────────────┐
│ MSG │ (Message / Event / Intent)
└──────┬───────┘
│
▼
┌──────────────┐ ┌──────────────┐ Cmd (Side-effect description) ┌──────────────┐
│ SUBSCRIPTION ├─► UPDATE ├───────────────────────────────────►│ RUNTIME │
│ (Sensors/GPS)│ │(Pure Reducer)│ │ (HTTP, Disk) │
└──────────────┘ └──────┬───────┘ └──────┬───────┘
│ newModel │ Result Msg
▼ │
┌──────────────┐ │
│ MODEL │ ──────────────────────────────────────────┘
│ (Immutable) │
└──────────────┘
-
Model: The single, immutable state of the application. -
View: A 100% pure projection:$\text{View}: \text{Model} \rightarrow \text{UI}$ . -
Update: A 100% pure function:$\text{Update}: (\text{Model}, \text{Msg}) \rightarrow (\text{NewModel}, \text{Cmd})$ . -
Side Effects as Data (
Cmd&Sub):- Functions never execute network calls or I/O directly.
-
Updatereturns data descriptions of side effects (Cmd). The runtime executes them and feeds results back asMsgs. - External continuous streams (Sensors, GPS) are declared as Subscriptions (
Sub).
- Philosophy: Mathematical reactive stream modeling via RxJava or Kotlin Coroutines Flow.
-
Core Flow:
$$\text{merge}(\text{intentFlow.flatMapMerge} {\text{toMutation}(it)}, \text{externalMutations}) \xrightarrow{\text{scan}} \text{State}$$
class ClassicMviViewModel(
sensorUseCase: ContinuousSensorUseCase
) : ViewModel() {
val externalMutations: Flow<Mutation> = sensorUseCase().map { Mutation.SensorUpdated(it) }
val uiState: StateFlow<State> by lazy {
merge(intentFlow.flatMapMerge(::toMutation), externalMutations)
.scan(initialState, ::reduce)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), initialState)
}
private fun reduce(previous: State, mutation: Mutation): State = when (mutation) {
is Mutation.SensorUpdated -> previous.copy(sensorText = mutation.data)
is Mutation.NoteAdded -> previous.copy(notes = previous.notes + mutation.note)
}
}- Pros: 100% pure state transitions; automatic unsubscription via
WhileSubscribed. - Cons: Massive boilerplate ("explosion of mutations"); async operations require complex Flow operators.
- Philosophy: Unidirectional Data Flow without redundant
Mutationclasses. - Core Flow: Declarative
combine+StateFlow.update { copy(...) }.
class ModernUdfViewModel(
sensorUseCase: ContinuousSensorUseCase,
batteryUseCase: BatteryLevelUseCase
) : ViewModel() {
private val _userState = MutableStateFlow(UserState())
val uiState: StateFlow<UiState> = combine(
_userState,
sensorUseCase(),
batteryUseCase()
) { user, sensor, battery ->
UiState(notes = user.notes, sensor = sensor, battery = battery)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState())
fun sendIntent(intent: Intent) {
when (intent) {
is Intent.AddNote -> _userState.update { it.copy(notes = it.notes + intent.note) }
}
}
}- Pros: Standard Google architecture; extremely readable; zero 3rd-party dependencies.
- Cons: Wide
combinecan create invalid state combinations if not properly grouped.
- Philosophy: "MVVM+": Coroutine-native DSL (
intent,reduce,postSideEffect). - Handling Cold Streams:
repeatOnSubscriptionmonitors container subscriber count.
class OrbitViewModel(
private val sensorUseCase: ContinuousSensorUseCase
) : ContainerHost<State, SideEffect>, ViewModel() {
override val container = container<State, SideEffect>(State())
init {
intent {
repeatOnSubscription { // 👈 Automatically pauses when UI is hidden!
sensorUseCase().collect { sensor ->
reduce { state.copy(sensorText = sensor) }
}
}
}
}
fun addNote(text: String) = intent {
reduce { state.copy(notes = state.notes + text) }
postSideEffect(SideEffect.Toast("Added!"))
}
}- Compose Integration:
val state by viewModel.collectAsState() viewModel.collectSideEffect { effect -> ... }
- Pros: Zero boilerplate; full lifecycle safety out of the box; KMP support.
- Philosophy: React-like
setState { copy() }with property-levelAsync<T>sealed wrappers. - How it handles async tasks:
.execute { copy(data = it) }automatically handlesUninitialized,Loading,Success,Fail.
data class ScreenState(
val listing: Async<Listing> = Uninitialized,
val batteryPercent: Int = 100
) : MavericksState
class ScreenViewModel : MavericksViewModel<ScreenState>(ScreenState()) {
fun fetchListing() {
repository.getListing()
.execute { copy(listing = it) } // Transitions through Loading -> Success/Fail
}
}- Compose Integration:
// Granular subscription avoids recomposing when other fields change: val battery by viewModel.collectAsStateWithLifecycle(ScreenState::batteryPercent)
- The Catch:
execute()runs inviewModelScopeand does NOT cancel continuous cold streams when backgrounded. Continuous streams must be exposed as separateStateFlows.
- Philosophy: Strict Elm Architecture (TEA) for enterprise state machines.
-
Core Flow:
$$\text{update}(\text{Model}, \text{Event}) \rightarrow \text{Next}<\text{Model}, \text{Set}<\text{Effect}>>$$
// 1. Pure Update Function (Zero Side-Effects)
fun update(model: Model, event: Event): Next<Model, Effect> = when (event) {
is Event.SaveClicked -> next(
model.copy(isSaving = true),
setOf(Effect.SaveToDb(event.text)) // 👈 Instruction only!
)
is Event.SensorTick -> {
if (model.isPaused) noChange() // 👈 Drops invalid event!
else next(model.copy(sensor = event.data))
}
}
// 2. EventSource (External streams)
val sensorEventSource = EventSource<Event> { consumer ->
val job = scope.launch { sensorUseCase().collect { consumer.accept(Event.SensorTick(it)) } }
Disposable { job.cancel() }
}- Lifecycle: Controlled imperatively via
controller.start()andcontroller.stop(). - Pros: 100% pure JUnit testable in 2ms without mocks; zero race conditions.
- Cons: Highest boilerplate (Events, Effects, Handlers, Controllers).
- Philosophy: "Compose-Native Architecture." Completely eliminates AndroidX
ViewModel. - Core Idea: Presenter is a
@Composablefunction; UI State embeds aneventSinkcallback.
data class CounterState(
val count: Int,
val sensorText: String,
val eventSink: (CounterEvent) -> Unit // 👈 Event handler lives in State!
) : CircuitUiState
class CounterPresenter(
private val sensorUseCase: ContinuousSensorUseCase
) : Presenter<CounterState> {
@Composable
override fun present(): CounterState {
var count by rememberRetained { mutableIntStateOf(0) }
val sensorText by produceState("...") {
sensorUseCase().collect { value = it } // Cancelled on dispose!
}
return CounterState(count, sensorText) { event ->
when (event) {
CounterEvent.Increment -> count++
}
}
}
}- Configuration Changes: Uses
rememberRetainedbacked by a rootRetainedStateRegistry(survives rotation without extendingViewModel).
- Philosophy: Multiplatform MVI (paired with Decompose) with formal separation between UI
Intentand externalAction. - Core Components:
Intent: User UI interactionsAction: External stream triggers / bootstrapMessage: Internal mutations feeding the reducerReducer: Purereduce(state, message) -> state
- Bootstrapper & Executor:
class MyBootstrapper(private val sensor: ContinuousSensorUseCase) : CoroutineBootstrapper<Action>() { override fun invoke() { scope.launch { sensor().collect { dispatch(Action.SensorUpdated(it)) } } } } class MyExecutor : CoroutineExecutor<Intent, Action, State, Message, Nothing>() { override fun executeAction(action: Action, getState: () -> State) { ... } override fun executeIntent(intent: Intent, getState: () -> State) { ... } }
- Superpower: Official IntelliJ IDEA plugin for visual Time-Travel Debugging.
- Philosophy: Plugin-driven, Coroutine-native MVI store.
- Handling Cold Streams: Built-in
whileSubscribedplugin.
val store = store<State, Intent, Nothing>(initial = State()) {
enableLogging()
manageJobs() // Auto-cancels coroutines on store destruction
// Only collects while UI is actively subscribed:
whileSubscribed {
sensorUseCase().consume { sensorData ->
updateState { copy(sensorText = sensorData) }
}
}
reduce { intent ->
when (intent) {
is Intent.Click -> updateState { copy(count = count + 1) }
}
}
}| Framework | Core Engine | Uses merge + scan? |
Cold Stream Lifecycle Handling | State Transition Style | Time-Travel Debugging? | Primary Target |
|---|---|---|---|---|---|---|
| Classic MVI | Kotlin Coroutines | YES | stateIn(WhileSubscribed) |
Pure reduce(state, mutation) |
Difficult | Reactive purists |
| Modern MVVM | Google Vanilla Flow | NO | combine(...).stateIn(WhileSubscribed) |
Inlined update { copy() } |
❌ No | Android industry standard |
| Orbit MVI | Coroutines DSL | NO | repeatOnSubscription (sub count + 100ms) |
Inlined reduce { state.copy() } |
❌ No | Modern Compose & KMP |
| Mavericks | Coroutines + Jetpack | NO | viewModelScope (must split) |
Inlined setState { copy() } |
❌ No | Fragment + Epoxy legacy |
| Mobius | Pure Elm Engine | YES (Events) | controller.start() / controller.stop() |
Pure update(model, event) -> Next |
YES | Automotive, Media, Fintech |
| Circuit | Compose Runtime | NO | Compose produceState / rememberRetained |
Direct Snapshot (count++) |
❌ No | Pure Compose apps |
| MVIKotlin | Multiplatform Stores | YES | CoroutineBootstrapper + Lifecycle Binder |
Pure reduce(state, message) |
YES (IDE Plugin) | KMP apps with Decompose |
| FlowMVI | Plugin Store | NO | whileSubscribed plugin |
Atomic updateState { copy() } |
Basic | Coroutine plugin fans |
Cold streams (e.g. GPS, vehicle telemetry, hardware sensors) must stop collecting when an app is in the background to save battery:
- In Flow pipelines (
stateIn(WhileSubscribed(5_000))/ Orbit / FlowMVI):
When Compose leavesSTARTED, the subscriber count drops to 0. After the timeout, cancellation propagates upstream to cancel the cold stream. - In Imperative Loops (Mobius):
controller.stop()explicitly calls.dispose()on allEventSources inonStop().
- Orbit (Implicit Reactive): The ViewModel has no reference to the Android Lifecycle. The only signal it has to know whether the UI is alive is checking whether
stateFlowhas active collectors (subscriptionCount > 0). - Mobius (Explicit Imperative): Android lifecycle callbacks directly command
controller.start()andcontroller.stop(). It doesn't need to count subscribers because it is explicitly ordered to start and stop.
In Airbnb Mavericks, .execute() runs in viewModelScope. Because viewModelScope is not cancelled in onStop(), infinite cold streams will run forever in the background.
Solution: Continuous cold streams in Mavericks must bypass execute and be exposed as a separate StateFlow via WhileSubscribed(5_000).
Combining 5 independent flows (combine(A, B, C, D, E)) creates an
- Sealed Interface +
flatMapLatest(Finite State Machine):
Model the screen asLoading,Error, andConnected. Only combine telemetry streams when in theConnectedbranch. - Domain Aggregator UseCase:
Group related streams in the domain layer (GetVehicleTelemetryUseCase) before they reach the ViewModel. Async<T>Wrapper (Mavericks):
Wrap each property inAsync<T>(Uninitialized,Loading,Success,Fail) so loading/error states are locally scoped.
Because pure MVI functions satisfy:
InitialState and an array of all [Events].
- Rewind: Click any past event to instantly reset the running UI to that point in time.
- Event Skipping: Toggle off an event to see what the state machine would produce without it.
- 1-Click Bug Reproduction: Export the event log as a
.jsonfile from a tester's device, import it into the IDE, and replay the exact bug with 100% determinism.