MVVM seemed simple. Then you added ten MutableStateFlow properties to your ViewModel. MVI promised purity. Then you wrote a middleware for side effects.
There’s a better way.
The Problem With MVVM
A typical ViewModel looks like this:
class ProfileViewModel : ViewModel() {
val name = MutableStateFlow("")
val email = MutableStateFlow("")
val isLoading = MutableStateFlow(false)
val error = MutableStateFlow<String?>(null)
fun updateProfile(name: String) {
viewModelScope.launch {
isLoading.value = true
try {
profileService.update(name)
this@ProfileViewModel.name.value = name
} catch (e: Exception) {
error.value = e.message
} finally {
isLoading.value = false
}
}
}
}
Five mutable properties. Loading state scattered across three places. Error handling duplicated in every method. And the ViewModel doesn’t own its side effects — viewModelScope does.
The Problem With MVI
MVI fixes the state explosion by putting everything in a sealed interface:
sealed interface ProfileState {
data object Loading : ProfileState
data class Loaded(val name: String, val email: String) : ProfileState
data class Error(val message: String) : ProfileState
}
But MVI doesn’t tell you how to handle side effects. Some use middleware. Some use Channel. Some hack it with LaunchedEffect. Every project reinvents the wheel.
Reduce & Conquer
The core idea: a reducer is a pure function that returns more than just state.
data class Transition<State, Event>(
val state: State,
val events: List<Event> = emptyList(),
val effects: List<Effect> = emptyList()
)
A transition has three outputs:
- State - the new state
- Events - one-shot notifications (navigation, snackbar)
- Effects - long-running or async work
Effects are first-class citizens:
sealed interface Effect {
data class Stream<Command>(
val key: Any,
val flow: Flow<Command>,
val strategy: Strategy = Strategy.Sequential,
val fallback: (suspend (Throwable) -> Command)? = null
) : Effect
data class Action<Command>(
val key: Any,
val fallback: (suspend (Throwable) -> Command)? = null,
val block: suspend () -> Command
) : Effect
data class Cancel(val key: Any) : Effect
}
Stream - subscribes to a flow, emits commands back
Action - runs one async operation, emits a command
Cancel - cancels by key, preventing leaks
A Reducer in Practice
class ProfileReducer(
private val profileService: ProfileService
) : Reducer<ProfileState, ProfileCommand, ProfileEvent> {
override fun reduce(
state: ProfileState,
command: ProfileCommand
): Transition<ProfileState, ProfileEvent> = when (command) {
is ProfileCommand.UpdateProfile -> transition(
state.copy(isLoading = true)
).effect(
action(
key = "update_profile",
fallback = { ProfileCommand.ProfileError(it) },
block = {
profileService.update(command.name)
ProfileCommand.ProfileUpdated
}
)
)
is ProfileCommand.ProfileUpdated -> transition(
state.copy(isLoading = false)
).event(ProfileEvent.NavigateBack)
is ProfileCommand.ProfileError -> transition(
state.copy(
isLoading = false,
error = command.throwable.message
)
)
}
}
No viewModelScope. No LaunchedEffect. No mutable properties. One pure function.
Why This Is The Benchmark
MVVM
- State: Multiple MutableStateFlow
- Side effects: viewModelScope.launch
- Cancellation: Manual
- Testing: Mock ViewModel
MVI
- State: Single sealed class/interface
- Side effects: Ad-hoc (middleware, Channel)
- Cancellation: Manual
- Testing: Mock reducer + middleware
Reduce & Conquer
- State: Single sealed interface
- Side effects: Built-in (Effect)
- Cancellation: Automatic by key
- Testing: One pure function call
Testing a reducer:
@Test
fun `update profile sets loading and fires effect`() {
val transition = reducer.reduce(
state = ProfileState(),
command = ProfileCommand.UpdateProfile("Alice")
)
assertTrue(transition.state.isLoading)
assertEquals(1, transition.effects.size)
assertEquals(0, transition.events.size)
}
One call. No coroutines. No mocks.
The Rule
State flows down. Commands flow up. Effects manage the rest.
The View sends Commands. The Reducer returns a Transition with new State, Events, and Effects. The Feature executes Effects and feeds resulting Commands back into the Reducer. That's it.
Full implementation in the GitHub repository.


Top comments (0)