Strict MVI on Android fails in the same ten ways in every codebase. This article is the pocket cheat sheet senior reviewers keep open during a pull-request pass. Each entry is a smell, a bad snippet, a good snippet, and one sentence explaining why it blocks the PR.
The ten
| # | Anti-pattern | Root cause |
|---|---|---|
| 1 | The God Intent | Reducer authority dissolved |
| 2 | Fat State | State shape sprawling |
| 3 | Side-Effecting Reducer | Reducer authority dissolved |
| 4 | Effect Used as Intent | Contract channel misused |
| 5 | Named Public Methods on the Store | Contract vocabulary duplicated |
| 6 | State-in-Effect via Navigate*
|
Contract channel misused |
| 7 |
SharedFlow for Effects |
Runtime drops emissions |
| 8 |
onEvent Wrapper Type |
Contract vocabulary duplicated |
| 9 | Middleware Mutating State Directly | Reducer authority dissolved |
| 10 | Swallowed CancellationException
|
Runtime leaks async detail |
Three root causes cover all ten. Something other than the reducer is authoring state (1, 3, 9). The contract channel or vocabulary is being duplicated or misused (4, 5, 6, 8). The runtime is leaking async detail that belongs inside a middleware (7, 10). Fat State (2) is the odd one out — a design smell that precedes any of the other nine. Each entry below is one concrete instance with the bad shape and the shape reviewers accept.
1. The God Intent
An Intent.Update(state) dissolves the reducer's authority. Every call site becomes a state author, and replay is impossible because the intent carries the result instead of the cause.
sealed interface FactsIntent : MviIntent {
data class Update(val state: FactsState) : FactsIntent
data class MergeInto(val partial: FactsState.() -> FactsState) : FactsIntent
}
sealed interface FactsIntent : MviIntent {
data object Refresh : FactsIntent
data object DismissError : FactsIntent
data class SearchChanged(val query: String) : FactsIntent
data class ToggleBookmark(val factId: String) : FactsIntent
// internal (middleware → reducer)
data class FactsReceived(val facts: ImmutableList<CatFact>) : FactsIntent
}
Why reviewers block it. Intents must describe cause, not effect; the reducer is the only place allowed to derive new state.
2. Fat State
A single state with 30-plus fields forces copy-on-update across unrelated concerns and produces a 300-line reducer no one can read.
data class FactsState(
val searchQuery: String, val searchOffset: Int, val searchHasMore: Boolean,
val searchIsLoading: Boolean, val searchError: UiText?,
val feedItems: ImmutableList<Feed>, val feedRefreshing: Boolean, val feedPage: Int,
val feedError: UiText?,
val bookmarkLoadingIds: ImmutableSet<String>, val bookmarkError: UiText?,
val settingsTheme: ThemeMode, val settingsDynamicColor: Boolean,
val settingsError: UiText?, /* ...26 more fields... */
)
// Split along feature seams, scoped to the same ViewModelComponent.
interface FeedStore : MviStore<FeedState, FeedIntent, FeedEffect>
interface FilterStore : MviStore<FilterState, FilterIntent, FilterEffect>
interface BookmarksStore : MviStore<BookmarksState, BookmarksIntent, BookmarksEffect>
Why reviewers block it. Fifteen fields is the soft ceiling; past it, unrelated concerns co-update in the same reducer branch and tests grow 30-field fixtures.
3. Side-Effecting Reducer
object FactsReducer : MviReducer<FactsState, FactsIntent> {
override fun reduce(state, intent) = when (intent) {
FactsIntent.Refresh -> {
repository.refresh()
logger.i("refresh")
eventTracker.trackEvent("refresh")
Reduction(state.copy(isRefreshing = true))
}
}
}
object FactsReducer : MviReducer<FactsState, FactsIntent> {
override fun reduce(state, intent) = when (intent) {
FactsIntent.Refresh -> Reduction(state.copy(isRefreshing = true))
// ... one branch per intent, no collaborators ...
}
}
Why reviewers block it. A reducer that calls a repository or logger is no longer pure, cannot be unit-tested without mocks, and breaks object purity by demanding injection.
4. Effect Used as Intent
A list of facts is renderable, so it is state — never a one-shot effect. Pushing state through the effect channel bypasses the reducer and loses data on rotation.
sealed interface FactsEffect : MviEffect {
data class UpdateFacts(val facts: ImmutableList<CatFact>) : FactsEffect
data class SetLoading(val loading: Boolean) : FactsEffect
data class NavigateToDetail(val fact: CatFact) : FactsEffect
}
sealed interface FactsIntent : MviIntent {
data class FactsReceived(val facts: ImmutableList<CatFact>) : FactsIntent
}
sealed interface FactsEffect : MviEffect {
data class NavigateToDetail(val factId: String) : FactsEffect
}
Why reviewers block it. Effects are one-shot and non-renderable; anything the UI can draw belongs in StateFlow<State>, and navigation effects carry identifiers only.
5. Named Public Methods on the Store
class FactsStoreImpl : BaseMviStore<...>() {
fun refresh() = send(FactsIntent.Refresh)
fun toggleBookmark(id: String) = send(FactsIntent.ToggleBookmark(id))
fun onSearchQueryChanged(q: String) = send(FactsIntent.SearchChanged(q))
}
// Route.kt
FactsScreen(
state = state,
onIntent = store::send, // the only entry point
modifier = modifier,
)
Why reviewers block it. Named methods re-introduce a parallel API on top of MVI; the contract is the intent type, not the Store's method list.
6. State-in-Effect via Navigate*(snapshot)
sealed interface FactsEffect : MviEffect {
data class NavigateToDetail(val fact: CatFact) : FactsEffect
}
sealed interface FactsEffect : MviEffect {
data class NavigateToDetail(val factId: String) : FactsEffect
}
Why reviewers block it. The destination screen binds to a payload that goes stale the moment the user returns; navigation effects must pass identifiers so the detail store fetches its own data.
7. SharedFlow for Effects
SharedFlow(replay = 0) drops every emission before a subscriber attaches — navigation and snackbars disappear on configuration change or transition.
private val _effects = MutableSharedFlow<FactsEffect>(replay = 0, extraBufferCapacity = 1)
val effects: SharedFlow<FactsEffect> = _effects.asSharedFlow()
private val _effects = Channel<FactsEffect>(Channel.BUFFERED)
val effects: Flow<FactsEffect> = _effects.receiveAsFlow()
Why reviewers block it. A Channel(BUFFERED) queues up to 64 effects until the Route attaches and delivers each exactly once; SharedFlow silently drops them or duplicates them if replay is raised.
8. onEvent Wrapper Type
data class FactsEvent(val intent: FactsIntent)
@Composable
fun FactsScreen(state: FactsState, onEvent: (FactsEvent) -> Unit, /* ... */)
onEvent(FactsEvent(FactsIntent.Refresh))
@Composable
fun FactsScreen(state: FactsState, onIntent: (FactsIntent) -> Unit, modifier: Modifier)
onIntent(FactsIntent.Refresh)
Why reviewers block it. A wrapper type duplicates the contract in call sites, tests, and analytics; the intent type is the contract and nothing should obscure it.
9. Middleware Mutating State Directly
class FactsDataMiddleware(private val store: FactsStoreImpl) : MviMiddleware<...> {
override fun process(intent, state, scope, dispatch, emit) {
store._state.update { it.copy(isLoading = true) }
}
}
override fun process(intent, state, scope, dispatch, emit) {
dispatch(FactsIntent.LoadingStarted) // reducer owns isLoading
}
Why reviewers block it. The reducer is the sole state authority; any bypass breaks the "state ⇐ intent" invariant and makes replay impossible.
10. Swallowed CancellationException
scope.launch {
try {
val result = longRunningCall()
dispatch(FactsIntent.Received(result))
} catch (e: Exception) {
dispatch(FactsIntent.Failed(e))
}
}
scope.launch {
try {
val result = longRunningCall()
dispatch(FactsIntent.Received(result))
} catch (e: CancellationException) { throw e }
catch (e: Exception) { dispatch(FactsIntent.Failed(e)) }
}
Why reviewers block it. A swallowed CancellationException turns cooperative cancellation into a failure stored as state.error, leaving a stale banner for an operation the user already cancelled.
Rule: every anti-pattern on this list has the same root cause — something other than an intent is authoring state, or something renderable is travelling as an effect.
Review-meeting pocket guide
| Scan for | Why it blocks the PR |
|---|---|
Intent.Update(State), Intent.Set*, or any intent whose payload is a full state snapshot. |
The intent is state-in-disguise; the reducer loses authority and replay becomes impossible. |
| A reducer that imports a repository, logger, analytics client, or clock. | Purity is gone; unit tests need mocks; time-travel and property-based fuzz tests break. |
SharedFlow anywhere — intents or effects — on a Store. |
Emissions drop before a subscriber attaches; navigation and snackbars vanish on configuration change. |
Named methods on the Store other than send(...); a Screen signature with more than one lambda besides modifier. |
A parallel API reappears on top of the intent type; analytics, tests, and capture replay split across two surfaces. |
catch (e: Exception) without a preceding catch (e: CancellationException) { throw e }, including before any withTimeout. |
Cooperative cancellation turns into a stored failure; stale error banners persist for operations the user already cancelled. |
suspend fun process(...) in a middleware, or a bounded intent buffer with check(tryEmit). |
A suspending middleware blocks the chain; a bounded intent buffer turns normal bursts into crashes. |
LaunchedEffect(Unit) { store.effects.collect { } } without repeatOnLifecycle(STARTED). |
Navigation effects fire into the back stack; snackbars land in hidden scaffolds on rotation. |
Raw Color(0xFF...), raw dp literals, raw user-visible strings; missing @Stable on Intent or Effect sealed roots. |
Design-system drift, non-localisable strings, and Compose skipping collapses on the intent lambda. |
A feature test folder with no FactsReducerTest.kt equivalent. |
Every intent deserves three reducer tests before any middleware or screen test; the pyramid has no base. |
Middleware injecting MviStore; middleware calling store._state.update. |
Hilt cycle and bypass of the reducer; the state ⇐ intent invariant collapses. |
The scan is ordered by the two questions that catch the most slips: what is authoring state that is not the reducer, and what is travelling through the effect channel that the UI needs to render. Both questions are answerable in under a minute on any diff, and the counterpart snippet in the matching section is the fix — no design meeting required.
Rule: if any row above fires, link this article in the review comment and paste the counterpart snippet from the matching section.





Top comments (0)