DEV Community

Cover image for 10 MVI Anti-Patterns Senior Android Reviewers Reject on Sight
Ouday khaled
Ouday khaled

Posted on • Originally published at expert-android-playbook.hashnode.dev

10 MVI Anti-Patterns Senior Android Reviewers Reject on Sight

Hero: 10 MVI anti-patterns reviewers reject on sight

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.

Bad vs good: Intent.Update vs explicit intents

sealed interface FactsIntent : MviIntent {
    data class Update(val state: FactsState) : FactsIntent
    data class MergeInto(val partial: FactsState.() -> FactsState) : FactsIntent
}
Enter fullscreen mode Exit fullscreen mode
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
}
Enter fullscreen mode Exit fullscreen mode

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.

Fat state split into sub-stores

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... */
)
Enter fullscreen mode Exit fullscreen mode
// 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>
Enter fullscreen mode Exit fullscreen mode

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

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.

Effect carrying state vs effect carrying id

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
}
Enter fullscreen mode Exit fullscreen mode
sealed interface FactsIntent : MviIntent {
    data class FactsReceived(val facts: ImmutableList<CatFact>) : FactsIntent
}
sealed interface FactsEffect : MviEffect {
    data class NavigateToDetail(val factId: String) : FactsEffect
}
Enter fullscreen mode Exit fullscreen mode

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))
}
Enter fullscreen mode Exit fullscreen mode
// Route.kt
FactsScreen(
    state = state,
    onIntent = store::send,   // the only entry point
    modifier = modifier,
)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode
sealed interface FactsEffect : MviEffect {
    data class NavigateToDetail(val factId: String) : FactsEffect
}
Enter fullscreen mode Exit fullscreen mode

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.

SharedFlow loses emissions; Channel buffers them

private val _effects = MutableSharedFlow<FactsEffect>(replay = 0, extraBufferCapacity = 1)
val effects: SharedFlow<FactsEffect> = _effects.asSharedFlow()
Enter fullscreen mode Exit fullscreen mode
private val _effects = Channel<FactsEffect>(Channel.BUFFERED)
val effects: Flow<FactsEffect> = _effects.receiveAsFlow()
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode
@Composable
fun FactsScreen(state: FactsState, onIntent: (FactsIntent) -> Unit, modifier: Modifier)

onIntent(FactsIntent.Refresh)
Enter fullscreen mode Exit fullscreen mode

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) }
    }
}
Enter fullscreen mode Exit fullscreen mode
override fun process(intent, state, scope, dispatch, emit) {
    dispatch(FactsIntent.LoadingStarted)   // reducer owns isLoading
}
Enter fullscreen mode Exit fullscreen mode

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))
    }
}
Enter fullscreen mode Exit fullscreen mode
scope.launch {
    try {
        val result = longRunningCall()
        dispatch(FactsIntent.Received(result))
    } catch (e: CancellationException) { throw e }
    catch (e: Exception) { dispatch(FactsIntent.Failed(e)) }
}
Enter fullscreen mode Exit fullscreen mode

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.

Where to go next

Top comments (0)