--- 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:
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:
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:
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.
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
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") } } } } }
In Compose, bind with `collectAsStateWithLifecycle()` for lifecycle awareness:
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) 