Compose Architecture, Done Right: MVI’s Unidirectional State vs. MVVM
TL;DR: Compose wants one-way data and a single UiState. MVI delivers that-fewer surprises, cleaner tests, and explicit effects.
Why MVVM Feels Clunky With Compose
MVVM grew up with XML and two-way data binding. Compose flipped the model: the UI is a function of state. That mismatch shows up in common pain points:
- Scattered state. Multiple
LiveData/Flows(loading, items, error, searchQuery, pagination…) mutate independently. Compose recomposes at odd times, and you start sprinklingremember { mutableStateOf(...) }to “patch” glitches. - The “SingleLiveEvent” saga. One-off actions (toasts, navigation, snackbars) don’t belong in your steady UI state, so teams hack in special event wrappers that break on configuration change or process death.
- Implicit writes. With two-way binding (or eager observers), it’s not obvious who changed what. You hunt bugs by grepping for setters.
- Brittle tests. It’s hard to reproduce a bug when the state can be mutated from multiple pathways and observer races.
What MVI Brings to the Table
Model-View-Intent (MVI) is just a strict recipe for unidirectional data flow:
- Intent (Event): User or system input (
Refresh,Retry,ItemClicked(id)). - Reducer: A pure function that turns
(oldState, event)intonewState(and maybe emits Effects). - State: A single immutable snapshot of what the UI should render.
- Effect: One-off actions that should not survive recomposition (navigation, toast, open sheet).
That’s it. You funnel everything through a predictable loop. Given the same starting state and the same event sequence, you get the same result-great for debugging and tests.
Why MVI Fits Compose So Well
- Single source of truth. Compose wants one
UiStatedriving your screen. MVI formalizes that contract. No accidental drift betweenloadinganditems. - Predictable recomposition. Immutable state + value equality means Compose can cheaply decide when to recompose. Fewer “why did this re-render?” moments.
- Effects are explicit. No more
SingleLiveEvent. You expose a separateFlow<UiEffect>and handle it in aLaunchedEffect. - Testability. Reducers are pure and deterministic-you can unit test them without the Android runtime. Effects can be asserted with a
Turbinecollector. - Traceability. Logging
(event, oldState -> newState)turns your bug reports into a replayable timeline.
A Pragmatic Migration (MVVM -> MVI-ish)
You don’t need to adopt a new framework. Keep your ViewModel, but model your screen with State + Events + Effects and a reducer-like update. Here’s a compact pattern you can drop into an existing codebase.
// UI contract
data class UiState(
val isLoading: Boolean = false,
val items: List<Item> = emptyList(),
val error: String? = null
)
sealed interface UiEvent {
data object Refresh : UiEvent
data class ItemClicked(val id: String) : UiEvent
}
sealed interface UiEffect {
data class ShowMessage(val text: String) : UiEffect
data class NavigateToDetail(val id: String) : UiEffect
}
// ViewModel = reducer + effect emitter
class MyViewModel(private val repo: Repo) : ViewModel() {
private val _state = MutableStateFlow(UiState())
val state: StateFlow<UiState> = _state
private val _effects = MutableSharedFlow<UiEffect>()
val effects: SharedFlow<UiEffect> = _effects
fun onEvent(event: UiEvent) = when (event) {
UiEvent.Refresh -> load()
is UiEvent.ItemClicked -> viewModelScope.launch {
_effects.emit(UiEffect.NavigateToDetail(event.id))
}
}
private fun load() = viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
runCatching { repo.fetchItems() }
.onSuccess { data -> _state.update { it.copy(isLoading = false, items = data) } }
.onFailure { e ->
_state.update { it.copy(isLoading = false, error = e.message) }
_effects.emit(UiEffect.ShowMessage("Load failed"))
}
}
}
@Composable
fun MyScreen(
vm: MyViewModel = hiltViewModel(),
onNavigate: (String) -> Unit,
showSnackbar: (String) -> Unit
) {
val state by vm.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
vm.effects.collect { effect ->
when (effect) {
is UiEffect.ShowMessage -> showSnackbar(effect.text)
is UiEffect.NavigateToDetail -> onNavigate(effect.id)
}
}
}
when {
state.isLoading -> LoadingView()
state.error != null -> ErrorView(
message = state.error,
onRetry = { vm.onEvent(UiEvent.Refresh) }
)
else -> ItemList(
items = state.items,
onClick = { id -> vm.onEvent(UiEvent.ItemClicked(id)) }
)
}
}
Notice what changed: the view layer renders from one snapshot and only talks back via onEvent. The ViewModel owns the truth and emits one-off effects separately. You’ve effectively shifted from MVVM’s “multiple mutable streams” to MVI’s “single state + intents,” without ditching ViewModel, Hilt, or your repository layer.
Performance and Ergonomics Tips
- Keep state small and value-typed. Favor data classes and immutable collections. Avoid putting lambdas, coroutines, or controllers in
UiState. - Split by feature. If a screen is large, use multiple small
UiStates or child reducers. Compose re-composes at the call-site granularity, so structure UI in small functions. - Use keys wisely. In
LazyColumn, supply stable keys to avoid churn during updates. - Derive what you can. Use
derivedStateOffor computed values (e.g.,isEmpty = items.isEmpty()), so you don’t store redundant flags that can go stale. - Debounce upstream. If your events are noisy (search typing), debounce in the ViewModel before reducing.
Testing the MVI Way
- Reducer tests: Given an initial
UiStateand an event, assert the new state. No Android framework required. - Effect tests: Collect
effectswith a testSharedFlowcollector and assert one-offs (NavigateToDetail) are emitted exactly once. - Integration tests: Use
runTestwith a fakeRepoto verify the load -> success/failure paths.
When You Might Stay With MVVM
If your screens are tiny, or your team already uses a “UDF-flavored MVVM” (single UiState + onEvent() + Effect), a wholesale rebrand to MVI might not buy much. The wins scale with complexity-pagination, offline/online, retries, permissions, and multi-source merges are where MVI shines.
Libraries, If You Want Them
You can roll your own (as above) or adopt a lightweight MVI helper library. Popular options provide reducers, intent dispatchers, and time-travel debugging. The key is to keep the contract (State/Event/Effect) stable so you can swap internals later.
Bottom Line
Compose is at its best when your UI is a pure function of a single state and all updates travel the same highway. MVI gives you that highway: fewer surprises, cleaner tests, and a shared language across your team. You don’t need a big rewrite — start by modeling one screen with UiState, route user actions through onEvent, and separate one-offs as UiEffect. After one or two screens, you’ll wonder how you ever managed the old way.
Opinions expressed by DZone contributors are their own.