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.
For further actions, you may consider blocking this person and/or reporting abuse
