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.
Join the DZone community and get the full member experience.
Join For FreeWhy 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.
Comments