DEV Community

Cover image for Stop Fighting Your State. Reduce And Conquer It.
numq
numq

Posted on

Stop Fighting Your State. Reduce And Conquer It.

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
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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()
)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode
  • 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
            )
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)