DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on Originally published at mvpfactory.io

MVI Architecture in Compose

---
title: "MVI in Compose: Kill Impossible States with Sealed Interfaces and a Pure Reducer"
published: true
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."
tags: kotlin, android, architecture, mobile
canonical_url: https://mvpfactory.co/blog/mvi-compose-sealed-interfaces-reducer
---

## What we are building

By the end of this workshop you will have a working `SearchViewModel` built on strict MVI — sealed interfaces for state and events, a pure reducer function, and `StateFlow` wired to Compose. More importantly, you will understand *why* this pattern makes an entire class of UI bugs structurally impossible.

## Prerequisites

- Kotlin 1.9+
- Jetpack Compose (any recent stable)
- Familiarity with `ViewModel` and coroutines
- A project using `androidx.lifecycle:lifecycle-viewmodel-compose`

---

## Step 1 — Understand why boolean flags rot

Here is the anti-pattern I see in almost every codebase I review:

Enter fullscreen mode Exit fullscreen mode


kotlin
// ❌ 2^3 = 8 possible states, only 3 are valid
data class SearchState(
val isLoading: Boolean = false,
val isError: Boolean = false,
val results: List = emptyList()
)


This compiles. It runs. And it will bite you at 2am when a race condition sets `isLoading = true` and `isError = true` simultaneously, rendering garbage silently. The fix is not better discipline — it is making invalid states unrepresentable.

---

## Step 2 — Model state as a sealed interface

Kotlin sealed interfaces are exhaustive by design. The `when` expression will not compile unless you handle every branch. Here is the minimal setup to get this working:

Enter fullscreen mode Exit fullscreen mode


kotlin
// ✅ Only 4 valid states can exist
sealed interface SearchUiState {
data object Idle : SearchUiState
data object Loading : SearchUiState
data class Success(val results: List) : SearchUiState
data class Error(val message: String) : SearchUiState
}


Events follow the same pattern:

Enter fullscreen mode Exit fullscreen mode


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


Let me show you a pattern I use in every project: keep states and events in the same file, named after the screen. `SearchUiState.kt` with both sealed interfaces is a single-glance contract for the entire screen.

---

## Step 3 — Write the pure reducer

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

Enter fullscreen mode Exit fullscreen mode


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


If your reducer is hard to unit test, it is doing too much.

---

## Step 4 — Wire it together with StateFlow

Enter fullscreen mode Exit fullscreen mode


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

}


In Compose, bind with `collectAsStateWithLifecycle()` for lifecycle awareness:

Enter fullscreen mode Exit fullscreen mode


kotlin
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

| Approach | Invalid states possible | Testable reducer | Compiler-enforced |
|---|---|---|---|
| Boolean flags | Yes (2ⁿ combos) | Difficult | No |
| Sealed class + `isLoading` hybrid | Partially | Partial | No |
| **Strict MVI + sealed interface** | **No** | **Yes** | **Yes** |

---

## Gotchas

**Do not launch coroutines inside the reducer.** The moment you add `viewModelScope.launch` there, you have broken purity and your tests need mocks. Keep the reducer a pure mapping function and push side effects to `handleSideEffect`.

**React to state, not to events, in `LaunchedEffect`.** Triggering a network call from `state == Loading` rather than from the event means recomposition cannot cause double-fetches. The docs do not emphasise this enough — it is easy to get wrong and genuinely annoying to track down when you do.

**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.

---

## Conclusion

Once a codebase commits to strict MVI, that whole category of state-related bugs disappears — not because developers became more careful, but because the compiler stopped allowing those states to exist. Start with the next screen you write. Find three boolean flags, replace them with a sealed interface, and watch the `when` expression do your QA for free.

**Further reading:**
- [Kotlin sealed interfaces — official docs](https://kotlinlang.org/docs/sealed-classes.html)
- [StateFlow and SharedFlow — Android docs](https://developer.android.com/kotlin/flow/stateflow-and-sharedflow)
- [collectAsStateWithLifecycle — lifecycle-runtime-compose](https://developer.android.com/reference/kotlin/androidx/lifecycle/compose/package-summary)
Enter fullscreen mode Exit fullscreen mode

Top comments (0)