MVP Factory
ai startup development

MVI architecture in Compose: kill impossible states

KW
Krystian Wiewiór · · 4 min read

Meta description: Learn how to implement strict MVI in Jetpack Compose using Kotlin sealed interfaces, pure reducer functions, and StateFlow to eliminate boolean flag hell and ship deterministic UI at scale.


TL;DR

Most Compose ViewModels rot into a pile of isLoading, isError, hasData booleans that can contradict each other. Strict MVI with sealed interfaces, a pure reducer, and StateFlow makes those combinations structurally impossible — the compiler becomes your QA team.


What most teams get wrong about state management

Performance is a function of correctness first, speed second. You cannot optimise a ViewModel that has 16 theoretically possible boolean combinations when only 4 of them are valid. I have reviewed codebases where isLoading && isError && data != null was reachable at runtime — and the UI would silently render garbage.

The fix is not better discipline. It is making the invalid states unrepresentable.


The boolean flag hell pattern (and why it breaks)

// ❌ The anti-pattern — 2^3 = 8 possible states, only 3 are valid
data class SearchState(
    val isLoading: Boolean = false,
    val isError: Boolean = false,
    val results: List<Item> = emptyList()
)

This compiles. It runs. It will bite you at 2am when a race condition sets isLoading = true and isError = true simultaneously.


Modeling state as a sealed interface

Kotlin’s sealed interfaces are exhaustive by design. The when expression won’t compile unless you handle every branch.

// ✅ Sealed interface — only 4 valid states exist
sealed interface SearchUiState {
    data object Idle : SearchUiState
    data object Loading : SearchUiState
    data class Success(val results: List<Item>) : SearchUiState
    data class Error(val message: String) : SearchUiState
}

Events follow the same pattern:

sealed interface SearchEvent {
    data class QueryChanged(val query: String) : SearchEvent
    data object RetryClicked : SearchEvent
    data object ClearClicked : SearchEvent
}

The pure reducer function

A reducer is a pure function: (State, Event) -> State. No side effects, no coroutine launches, no repository calls. This makes it trivially unit-testable.

fun reduce(state: SearchUiState, event: SearchEvent): SearchUiState =
    when (event) {
        is SearchEvent.QueryChanged -> SearchUiState.Loading
        SearchEvent.RetryClicked    -> SearchUiState.Loading
        SearchEvent.ClearClicked    -> SearchUiState.Idle
    }

Side effects (network calls, navigation) are handled separately as intents triggered by state transitions — not inside the reducer.


Wiring it together with StateFlow

class SearchViewModel(private val repo: SearchRepository) : ViewModel() {

    private val _state = MutableStateFlow<SearchUiState>(SearchUiState.Idle)
    val state: StateFlow<SearchUiState> = _state.asStateFlow()

    fun onEvent(event: SearchEvent) {
        _state.update { current -> reduce(current, event) }
        handleSideEffect(event)
    }

    private fun handleSideEffect(event: SearchEvent) {
        if (event is SearchEvent.QueryChanged) {
            viewModelScope.launch {
                _state.value = try {
                    SearchUiState.Success(repo.search(event.query))
                } catch (e: Exception) {
                    SearchUiState.Error(e.message ?: "Unknown error")
                }
            }
        }
    }
}

In Compose, collectAsStateWithLifecycle() binds the StateFlow with lifecycle awareness:

val state by viewModel.state.collectAsStateWithLifecycle()

when (val s = state) {
    SearchUiState.Idle    -> IdleScreen()
    SearchUiState.Loading -> LoadingIndicator()
    is SearchUiState.Success -> ResultsList(s.results)
    is SearchUiState.Error   -> ErrorMessage(s.message)
}

The compiler enforces exhaustiveness. You cannot forget the error case.


Pattern comparison

ApproachInvalid states possibleUnit testable reducerCompiler-enforcedScales to 10+ states
Boolean flagsYes (2ⁿ combos)DifficultNoNo
Sealed class + isLoading hybridPartiallyPartialNoPoorly
Strict MVI + sealed interfaceNoYesYesYes

In my experience, once a codebase commits to strict MVI, that whole category of state-related bugs just disappears — not because developers got more careful, but because the compiler stopped allowing those states to exist.


Where to start

Three is* booleans on the same concern is the tell. When you spot isLoading, isError, and hasData sitting together, that is a sealed interface waiting to be written — not a discipline problem, a modeling problem.

Keep the reducer pure. No launch, no repository calls inside it. If the reducer is hard to unit test, it is doing too much. It should map (State, Event) -> State and nothing else. Twenty lines, zero mocks.

Put side effects in LaunchedEffect, not in onEvent. Reacting to state == Loading rather than to the triggering event means recomposition cannot cause double-fetches. This one is easy to get wrong and genuinely annoying to track down when you do.


#kotlin #android #jetpackcompose #architecture #cleanarchitecture


Share: Twitter LinkedIn