VOOZH about

URL: https://dev.to/numq/stop-fighting-your-state-reduce-and-conquer-it-1ijf

⇱ Stop Fighting Your State. Reduce And Conquer It. - DEV Community


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

👁 Reduce & Conquer layers

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.

👁 Reduce & Conquer diagram

Full implementation in the GitHub repository.