Stop Fighting Your State. Reduce And Conquer It.

Stop Fighting Your State. Reduce And Conquer It.

posted Originally published at medium.com 2 min read

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)
                *Emails are not allowed*e = 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.

More Posts

Your AI Doesn't Just Write Tests. It Runs Them Too.

Kevin Martinez - May 12

Your Backup Data Knows More Than You Think. HYCU aiR Is Finally Asking It the Right Questions.

Tom Smithverified - May 14

How to Reduce Your AWS Bill by 50%

rogo032 - Jan 27

Your Tech Stack Isn’t Your Ceiling. Your Story Is

Karol Modelskiverified - Apr 9

I’m a Senior Dev and I’ve Forgotten How to Think Without a Prompt

Karol Modelskiverified - Mar 19
chevron_left

Related Jobs

View all jobs →

Commenters (This Week)

2 comments
1 comment
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!