← Back to list

Kotlin Coroutines Part 10: MVI With Coroutines: One State Flow, an Intent Channel, a Pure Reducer…

Parts 1–9 gave you the pieces: structured concurrency, dispatchers, scopes, cancellation, exceptions, cold vs hot flows, operators…

Ramadan Sayed · 2026-08-16 13:17 · 0 claps · 12.6 min read paywalled
#kotlin-coroutines #coroutine #kotlin
Open on Medium ↗
Wiki topics: 📱 · Mobile Development

Kotlin Coroutines Part 10: MVI With Coroutines: One State Flow, an Intent Channel, a Pure Reducer, Side Effects Without Leaks, Why Independent Section States Beat a Single Monolithic Loading Flag, Handling Dashboard Screens Where Every Section Loads and Fails Separately, SavedStateHandle Integration, and a Complete Production Implementation You Can Copy

Parts 1–9 gave you the pieces: structured concurrency, dispatchers, scopes, cancellation, exceptions, cold vs hot flows, operators, buffering, and combining. This article assembles them into an architecture.

MVI — Model-View-Intent — is a good fit for coroutines specifically because its three moving parts map cleanly onto three Flow concepts. State is a StateFlow. Intents are a Channel. Side effects are another Channel. The reducer is a pure function. Everything else — cancellation, lifecycle, error containment — falls out of what we've already covered.

But most MVI articles show you a toy counter and stop. The interesting problems start when a screen has six sections that load independently, three of which can fail without the screen being broken, one of which depends on another, and all of which need to survive rotation. A single isLoading: Boolean and a single error: String? cannot model that, and trying to force it produces the sad screen everyone has seen: one section fails, the whole page shows an error, and the five sections that loaded fine are hidden behind it.

This article covers MVI properly: the core loop, the pure reducer, side effects, and then the section-state pattern for real dashboards. Everything is production code you can copy.

Part 1: The Core Loop

┌──────────────────────────────────────────────┐
        │                                              │
        ▼                                              │
   ┌─────────┐    Intent    ┌──────────┐               │
   │  View   │ ───────────► │ViewModel │               │
   └─────────┘              └────┬─────┘               │
        ▲                        │                     │
        │                        ▼                     │
        │                  ┌───────────┐               │
        │                  │  Reducer  │  (pure)       │
        │                  └─────┬─────┘               │
        │                        │                     │
        │       State            ▼                     │
        └──────────────────  StateFlow ────────────────┘
                                 │
                                 │  Effect
                                 ▼
                            Channel ──► one-shot: navigate, snackbar

Four types per screen:

// 1. STATE — everything the UI renders. Immutable.
@Immutable
data class ScreenState(...)

// 2. INTENT - everything the user can do.
sealed interface ScreenIntent
// 3. EFFECT - one-shot things that aren't state.
sealed interface ScreenEffect
// 4. The ViewModel that ties them together.
class ScreenViewModel : ViewModel()

Part 2: A Minimal Correct Implementation

// ── State ──────────────────────────────────────────────────────
@Immutable
data class CounterState(
    val count: Int = 0,
    val isLoading: Boolean = false
)

// ── Intent ─────────────────────────────────────────────────────
sealed interface CounterIntent {
    data object Increment : CounterIntent
    data object Decrement : CounterIntent
    data object Reset : CounterIntent
    data object Save : CounterIntent
}
// ── Effect ─────────────────────────────────────────────────────
sealed interface CounterEffect {
    data class ShowSnackbar(val message: String) : CounterEffect
    data object NavigateBack : CounterEffect
}
// ── ViewModel ──────────────────────────────────────────────────
@HiltViewModel
class CounterViewModel @Inject constructor(
    private val repository: CounterRepository
) : ViewModel() {
    private val _state = MutableStateFlow(CounterState())
    val state: StateFlow<CounterState> = _state.asStateFlow()
    private val _effects = Channel<CounterEffect>(Channel.BUFFERED)
    val effects: Flow<CounterEffect> = _effects.receiveAsFlow()
    private val intents = Channel<CounterIntent>(Channel.UNLIMITED)
    init {
        viewModelScope.launch {
            intents.consumeAsFlow().collect { intent -> handle(intent) }
        }
    }
    fun onIntent(intent: CounterIntent) {
        intents.trySend(intent)         // non-suspending - safe from any callback
    }
    private suspend fun handle(intent: CounterIntent) {
        when (intent) {
            // Pure state transitions - synchronous
            CounterIntent.Increment -> _state.update { reduce(it, intent) }
            CounterIntent.Decrement -> _state.update { reduce(it, intent) }
            CounterIntent.Reset     -> _state.update { reduce(it, intent) }
            // Async work + effect
            CounterIntent.Save -> save()
        }
    }
    private suspend fun save() {
        _state.update { it.copy(isLoading = true) }
        runCatchingCancellable { repository.save(_state.value.count) }
            .onSuccess {
                _state.update { it.copy(isLoading = false) }
                _effects.send(CounterEffect.ShowSnackbar("Saved"))
            }
            .onFailure { e ->
                _state.update { it.copy(isLoading = false) }
                _effects.send(CounterEffect.ShowSnackbar(e.message ?: "Save failed"))
            }
    }
}
// ── Reducer - PURE. No coroutines, no I/O, no side effects. ────
private fun reduce(state: CounterState, intent: CounterIntent): CounterState =
    when (intent) {
        CounterIntent.Increment -> state.copy(count = state.count + 1)
        CounterIntent.Decrement -> state.copy(count = state.count - 1)
        CounterIntent.Reset     -> state.copy(count = 0)
        CounterIntent.Save      -> state              // handled asynchronously
    }

Why an Intent Channel Rather Than Direct Method Calls

You could skip the channel and just have fun increment(). Many teams do, and it's fine for simple screens. The channel buys you three things:

  1. Serialized processing. Intents are handled one at a time, in order, on a single coroutine. No interleaving, no races between two rapid taps.
  2. A single funnel to instrument. One collect to log every user action, replay in tests, or feed to analytics.
  3. **trySend is non-suspending.** Safe to call from any callback — onClick, a gesture handler, a BroadcastReceiver — without needing a scope.

Channel.UNLIMITED for intents is intentional: user actions must never be dropped, and the volume is inherently tiny (nobody taps 10,000 times per second).

Why the Reducer Is Pure

// ✅ Pure — trivially testable, no mocks
private fun reduce(state: State, intent: Intent): State

// ❌ Impure - needs a test dispatcher, mocks, and a coroutine test harness
private suspend fun reduce(state: State, intent: Intent): State {
    val data = repository.fetch()      // I/O in the reducer
    return state.copy(data = data)
}

Keep I/O out of the reducer. The reducer decides what the state becomes; suspending handlers decide what work to do.

Part 3: Consuming It From the UI

Compose

@Composable
fun CounterScreen(
    viewModel: CounterViewModel = hiltViewModel(),
    onNavigateBack: () -> Unit
) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    val snackbarHost = remember { SnackbarHostState() }

val lifecycleOwner = LocalLifecycleOwner.current
    LaunchedEffect(lifecycleOwner) {
        lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
            viewModel.effects.collect { effect ->
                when (effect) {
                    is CounterEffect.ShowSnackbar -> snackbarHost.showSnackbar(effect.message)
                    CounterEffect.NavigateBack -> onNavigateBack()
                }
            }
        }
    }
    Scaffold(snackbarHost = { SnackbarHost(snackbarHost) }) { padding ->
        CounterContent(
            state = state,
            onIntent = viewModel::onIntent,      // ← single entry point
            modifier = Modifier.padding(padding)
        )
    }
}
@Composable
private fun CounterContent(
    state: CounterState,
    onIntent: (CounterIntent) -> Unit,
    modifier: Modifier = Modifier
) {
    Column(modifier) {
        Text("Count: ${state.count}")
        Button(onClick = { onIntent(CounterIntent.Increment) }) { Text("+") }
        Button(onClick = { onIntent(CounterIntent.Decrement) }) { Text("−") }
        Button(
            onClick = { onIntent(CounterIntent.Save) },
            enabled = !state.isLoading
        ) { Text("Save") }
    }
}

CounterContent takes only state and onIntent — no ViewModel. It's fully previewable and testable in isolation.

Fragment

class CounterFragment : Fragment(R.layout.fragment_counter) {

private val viewModel: CounterViewModel by viewModels()
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        val binding = FragmentCounterBinding.bind(view)
        binding.incrementButton.setOnClickListener {
            viewModel.onIntent(CounterIntent.Increment)
        }
        binding.saveButton.setOnClickListener {
            viewModel.onIntent(CounterIntent.Save)
        }
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                launch {
                    viewModel.state.collect { state ->
                        binding.countText.text = state.count.toString()
                        binding.saveButton.isEnabled = !state.isLoading
                    }
                }
                launch {
                    viewModel.effects.collect { effect ->
                        when (effect) {
                            is CounterEffect.ShowSnackbar ->
                                Snackbar.make(view, effect.message, Snackbar.LENGTH_SHORT).show()
                            CounterEffect.NavigateBack -> findNavController().popBackStack()
                        }
                    }
                }
            }
        }
    }
}

Exactly the Part 3 pattern: view lifecycle, repeatOnLifecycle, nested launches.

Part 4: The Monolithic-Loading-Flag Problem

Here’s the shape that breaks on real screens:

// ❌ Cannot model a dashboard
@Immutable
data class DashboardState(
    val balance: Balance? = null,
    val transactions: List<Transaction> = emptyList(),
    val offers: List<Offer> = emptyList(),
    val insights: Insights? = null,
    val isLoading: Boolean = false,       // ⚠️ loading WHAT?
    val error: String? = null             // ⚠️ which section failed?
)

Concrete failures:

  • Balance loads in 200ms, insights take 3 seconds. isLoading stays true for 3 seconds, so the balance is hidden behind a spinner for 2.8 seconds longer than necessary.
  • Offers fail (a non-critical marketing endpoint). error is set. The UI shows a full-screen error and hides the balance, transactions, and insights that all loaded perfectly.
  • The user retries. What does retry mean? Reload everything, including the four sections that worked?
  • Two sections fail. The second overwrites the first’s error message.

A single boolean and a single string cannot represent four independent async operations. The state model is simply wrong.

Part 5: Independent Section States

Model each section as its own state machine.

// A reusable, generic section state
@Immutable
sealed interface SectionState<out T> {
    data object Idle : SectionState<Nothing>
    data object Loading : SectionState<Nothing>
    data class Success<T>(val data: T) : SectionState<T>
    data class Error(val message: String, val retryable: Boolean = true) : SectionState<Nothing>
}

// Convenience accessors
val <T> SectionState<T>.dataOrNull: T?
    get() = (this as? SectionState.Success)?.data
val SectionState<*>.isLoading: Boolean
    get() = this is SectionState.Loading
// The screen state - one section state per section
@Immutable
data class DashboardState(
    val balance: SectionState<Balance> = SectionState.Idle,
    val transactions: SectionState<ImmutableList<Transaction>> = SectionState.Idle,
    val offers: SectionState<ImmutableList<Offer>> = SectionState.Idle,
    val insights: SectionState<Insights> = SectionState.Idle
) {
    // Derived properties - computed, not stored
    val isAnythingLoading: Boolean
        get() = balance.isLoading || transactions.isLoading ||
                offers.isLoading || insights.isLoading
    val hasCriticalFailure: Boolean
        get() = balance is SectionState.Error      // only balance is load-critical
}

Now every section renders its own loading, success, and error state independently. Balance appears at 200ms. Insights show a spinner until 3s. A failed offers section shows an inline retry card while everything else works.

Part 6: A Complete Dashboard ViewModel

@HiltViewModel
class DashboardViewModel @Inject constructor(
    private val repository: DashboardRepository,
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {

private val accountId: String = checkNotNull(savedStateHandle["accountId"])
    private val _state = MutableStateFlow(DashboardState())
    val state: StateFlow<DashboardState> = _state.asStateFlow()
    private val _effects = Channel<DashboardEffect>(Channel.BUFFERED)
    val effects: Flow<DashboardEffect> = _effects.receiveAsFlow()
    private val intents = Channel<DashboardIntent>(Channel.UNLIMITED)
    // One Job per section - a section reload cancels only its own in-flight work
    private val sectionJobs = mutableMapOf<Section, Job>()
    init {
        viewModelScope.launch {
            intents.consumeAsFlow().collect { handle(it) }
        }
        onIntent(DashboardIntent.LoadAll)
    }
    fun onIntent(intent: DashboardIntent) {
        intents.trySend(intent)
    }
    private fun handle(intent: DashboardIntent) {
        when (intent) {
            DashboardIntent.LoadAll -> {
                Section.entries.forEach { loadSection(it) }
            }
            is DashboardIntent.RetrySection -> loadSection(intent.section)
            DashboardIntent.Refresh -> {
                Section.entries.forEach { loadSection(it) }
            }
            is DashboardIntent.TransactionClicked -> {
                viewModelScope.launch {
                    _effects.send(DashboardEffect.NavigateToTransaction(intent.id))
                }
            }
            is DashboardIntent.OfferDismissed -> dismissOffer(intent.id)
        }
    }
    // ── The core pattern - each section loads independently ────────
    private fun loadSection(section: Section) {
        sectionJobs[section]?.cancel()                 // cancel only this section
        sectionJobs[section] = viewModelScope.launch {
            updateSection(section, SectionState.Loading)
            val result = runCatchingCancellable {
                when (section) {
                    Section.BALANCE      -> repository.getBalance(accountId)
                    Section.TRANSACTIONS -> repository.getTransactions(accountId)
                    Section.OFFERS       -> repository.getOffers(accountId)
                    Section.INSIGHTS     -> repository.getInsights(accountId)
                }
            }
            result
                .onSuccess { data -> updateSection(section, SectionState.Success(data)) }
                .onFailure { e ->
                    updateSection(
                        section,
                        SectionState.Error(
                            message = e.toUserMessage(),
                            retryable = e is IOException
                        )
                    )
                    // Only surface a snackbar for the critical section
                    if (section == Section.BALANCE) {
                        _effects.send(DashboardEffect.ShowError(e.toUserMessage()))
                    }
                }
        }
    }
    @Suppress("UNCHECKED_CAST")
    private fun updateSection(section: Section, sectionState: SectionState<*>) {
        _state.update { current ->
            when (section) {
                Section.BALANCE      -> current.copy(balance = sectionState as SectionState<Balance>)
                Section.TRANSACTIONS -> current.copy(transactions = sectionState as SectionState<ImmutableList<Transaction>>)
                Section.OFFERS       -> current.copy(offers = sectionState as SectionState<ImmutableList<Offer>>)
                Section.INSIGHTS     -> current.copy(insights = sectionState as SectionState<Insights>)
            }
        }
    }
    private fun dismissOffer(id: String) {
        _state.update { current ->
            val offers = current.offers
            if (offers !is SectionState.Success) return@update current
            current.copy(
                offers = SectionState.Success(offers.data.removeAll { it.id == id })
            )
        }
        viewModelScope.launch {
            runCatchingCancellable { repository.dismissOffer(id) }
        }
    }
}
enum class Section { BALANCE, TRANSACTIONS, OFFERS, INSIGHTS }
sealed interface DashboardIntent {
    data object LoadAll : DashboardIntent
    data object Refresh : DashboardIntent
    data class RetrySection(val section: Section) : DashboardIntent
    data class TransactionClicked(val id: String) : DashboardIntent
    data class OfferDismissed(val id: String) : DashboardIntent
}
sealed interface DashboardEffect {
    data class NavigateToTransaction(val id: String) : DashboardEffect
    data class ShowError(val message: String) : DashboardEffect
}

What Each Decision Buys You

Decision Benefit One Job per section Retrying offers doesn't cancel the in-flight insights load SectionState per section Each section renders its own loading/error independently runCatchingCancellable per section A failure is contained; cancellation still propagates Effect only for the critical section No snackbar spam when a marketing endpoint is down retryable flag on the error The UI knows whether to show a retry button Optimistic update in dismissOffer UI responds instantly; the network call follows

The UI

@Composable
fun DashboardScreen(
    viewModel: DashboardViewModel = hiltViewModel(),
    onNavigateToTransaction: (String) -> Unit
) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    val snackbarHost = remember { SnackbarHostState() }

val lifecycleOwner = LocalLifecycleOwner.current
    LaunchedEffect(lifecycleOwner) {
        lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
            viewModel.effects.collect { effect ->
                when (effect) {
                    is DashboardEffect.NavigateToTransaction -> onNavigateToTransaction(effect.id)
                    is DashboardEffect.ShowError -> snackbarHost.showSnackbar(effect.message)
                }
            }
        }
    }
    Scaffold(snackbarHost = { SnackbarHost(snackbarHost) }) { padding ->
        LazyColumn(modifier = Modifier.padding(padding)) {
            item {
                SectionContainer(
                    sectionState = state.balance,
                    onRetry = { viewModel.onIntent(DashboardIntent.RetrySection(Section.BALANCE)) }
                ) { balance -> BalanceCard(balance) }
            }
            item {
                SectionContainer(
                    sectionState = state.offers,
                    onRetry = { viewModel.onIntent(DashboardIntent.RetrySection(Section.OFFERS)) }
                ) { offers ->
                    OffersCarousel(
                        offers = offers,
                        onDismiss = { viewModel.onIntent(DashboardIntent.OfferDismissed(it)) }
                    )
                }
            }
            item {
                SectionContainer(
                    sectionState = state.transactions,
                    onRetry = { viewModel.onIntent(DashboardIntent.RetrySection(Section.TRANSACTIONS)) }
                ) { txns ->
                    TransactionList(
                        transactions = txns,
                        onClick = { viewModel.onIntent(DashboardIntent.TransactionClicked(it)) }
                    )
                }
            }
        }
    }
}
// One generic container handles all four states for every section
@Composable
fun <T> SectionContainer(
    sectionState: SectionState<T>,
    onRetry: () -> Unit,
    modifier: Modifier = Modifier,
    content: @Composable (T) -> Unit
) {
    when (sectionState) {
        SectionState.Idle -> Unit
        SectionState.Loading -> SectionSkeleton(modifier)
        is SectionState.Success -> content(sectionState.data)
        is SectionState.Error -> SectionError(
            message = sectionState.message,
            onRetry = onRetry.takeIf { sectionState.retryable },
            modifier = modifier
        )
    }
}

One SectionContainer composable handles loading, error, and retry for every section on the screen. Adding a fifth section is one enum entry, one state field, and one item { } block.

Part 7: SavedStateHandle — Surviving Process Death

viewModelScope survives rotation but not process death. For state the user would be upset to lose, use SavedStateHandle.

@HiltViewModel
class SearchViewModel @Inject constructor(
    private val repository: SearchRepository,
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {

// Survives process death - Android restores it from the saved Bundle
    private val _query = savedStateHandle.getStateFlow(KEY_QUERY, "")
    val query: StateFlow<String> = _query
    private val _filters = savedStateHandle.getStateFlow(KEY_FILTERS, SearchFilters())
    @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
    val results: StateFlow<SectionState<ImmutableList<Result>>> =
        combine(_query.debounce(300), _filters) { q, f -> q to f }
            .filter { (q, _) -> q.length >= 2 }
            .distinctUntilChanged()
            .flatMapLatest { (q, f) ->
                repository.search(q, f)
                    .map<ImmutableList<Result>, SectionState<ImmutableList<Result>>> {
                        SectionState.Success(it)
                    }
                    .onStart { emit(SectionState.Loading) }
                    .catch { emit(SectionState.Error(it.toUserMessage())) }
            }
            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SectionState.Idle)
    fun onIntent(intent: SearchIntent) {
        when (intent) {
            is SearchIntent.QueryChanged -> savedStateHandle[KEY_QUERY] = intent.query
            is SearchIntent.FiltersChanged -> savedStateHandle[KEY_FILTERS] = intent.filters
        }
    }
    companion object {
        private const val KEY_QUERY = "query"
        private const val KEY_FILTERS = "filters"
    }
}

savedStateHandle.getStateFlow(key, default) gives you a StateFlow backed by the saved state bundle. Writing via savedStateHandle[key] = value both updates the flow and persists it. It's the cleanest integration point between MVI and process death.

Note the constraint: values must be Bundle-compatible (primitives, Parcelable, Serializable).

Part 8: Testing MVI

The architecture is designed to be testable. Part 11 covers the coroutine testing toolkit in depth; here’s the shape.

The Reducer — No Coroutines At All

class CounterReducerTest {
    @Test
    fun `increment increases count`() {
        val state = CounterState(count = 5)
        val result = reduce(state, CounterIntent.Increment)
        assertEquals(6, result.count)
    }

@Test
    fun `reset returns count to zero`() {
        val state = CounterState(count = 42)
        val result = reduce(state, CounterIntent.Reset)
        assertEquals(0, result.count)
    }
}

No mocks, no dispatchers, no test harness. This is the payoff for keeping the reducer pure.

The ViewModel — With Turbine

class DashboardViewModelTest {

@get:Rule val mainDispatcherRule = MainDispatcherRule()
    @Test
    fun `sections load independently`() = runTest {
        val repository = FakeDashboardRepository().apply {
            balanceDelay = 100
            offersShouldFail = true
        }
        val viewModel = DashboardViewModel(repository, SavedStateHandle(mapOf("accountId" to "1")))
        viewModel.state.test {
            awaitItem()                                   // initial: all Idle
            val loading = awaitItem()
            assertTrue(loading.balance is SectionState.Loading)
            advanceTimeBy(150)
            val settled = expectMostRecentItem()
            assertTrue(settled.balance is SectionState.Success)   // balance loaded
            assertTrue(settled.offers is SectionState.Error)      // offers failed
            // ✅ balance is usable despite offers failing
        }
    }
    @Test
    fun `retrying one section does not reload others`() = runTest {
        val repository = FakeDashboardRepository()
        val viewModel = DashboardViewModel(repository, SavedStateHandle(mapOf("accountId" to "1")))
        advanceUntilIdle()
        val balanceCallsBefore = repository.balanceCallCount
        viewModel.onIntent(DashboardIntent.RetrySection(Section.OFFERS))
        advanceUntilIdle()
        assertEquals(balanceCallsBefore, repository.balanceCallCount)   // unchanged
        assertEquals(2, repository.offersCallCount)                     // reloaded
    }
}

Effects

@Test
fun `clicking a transaction emits a navigation effect`() = runTest {
    val viewModel = DashboardViewModel(FakeDashboardRepository(), SavedStateHandle(mapOf("accountId" to "1")))

viewModel.effects.test {
        viewModel.onIntent(DashboardIntent.TransactionClicked("txn-42"))
        val effect = awaitItem()
        assertEquals(DashboardEffect.NavigateToTransaction("txn-42"), effect)
    }
}

Part 9: Anti-Patterns

1. A Single isLoading on a Multi-Section Screen

// ❌
data class State(val a: A?, val b: B?, val isLoading: Boolean, val error: String?)
// ✅
data class State(val a: SectionState<A>, val b: SectionState<B>)

2. Effects in StateFlow

// ❌ Replays on rotation → double navigation
data class State(val navigateTo: String? = null)
// ✅
private val _effects = Channel<Effect>(Channel.BUFFERED)

Covered fully in Part 6.

3. I/O in the Reducer

// ❌ No longer pure, no longer trivially testable
private suspend fun reduce(state: State, intent: Intent): State {
    return state.copy(data = repository.fetch())
}
// ✅ Reducer decides state; a suspending handler does work

4. One Job for All Sections

// ❌ Retrying one section cancels every other in-flight load
private var loadJob: Job? = null
// ✅
private val sectionJobs = mutableMapOf<Section, Job>()

5. Mutable Collections in State

// ❌ Mutation doesn't change the reference → StateFlow emits nothing
data class State(val items: MutableList<Item> = mutableListOf())
// ✅
@Immutable data class State(val items: ImmutableList<Item> = persistentListOf())

6. _state.value = for Derived Updates

// ❌ Race between two coroutines
_state.value = _state.value.copy(n = _state.value.n + 1)
// ✅
_state.update { it.copy(n = it.n + 1) }

7. Passing the ViewModel Into Content Composables

// ❌ Not previewable, not unit-testable
@Composable fun Content(viewModel: MyViewModel) { }
// ✅
@Composable fun Content(state: State, onIntent: (Intent) -> Unit) { }

8. Channel.RENDEZVOUS for Intents

// ❌ trySend fails silently — user actions are dropped
private val intents = Channel<Intent>()
// ✅
private val intents = Channel<Intent>(Channel.UNLIMITED)

Part 10: Checklist

STRUCTURE
□ Four types per screen: State, Intent, Effect, ViewModel
□ State is @Immutable with all val properties
□ Collections in state are ImmutableList / persistentList
□ Reducer is a pure function — no suspend, no I/O
□ Content composables take (state, onIntent), never a ViewModel

STATE MODELLING
□ Multi-section screens use SectionState per section
□ No single isLoading / error for independent operations
□ Derived values are computed properties, not stored fields
□ State the user would hate to lose is in SavedStateHandle
EFFECTS
□ One-shot events go through Channel + receiveAsFlow, never StateFlow
□ Effects collected with repeatOnLifecycle(STARTED)
□ Effect channel is BUFFERED so events survive backgrounding
CONCURRENCY
□ Intent channel is UNLIMITED - user actions never dropped
□ One Job per independently-cancellable operation
□ runCatchingCancellable used, not stdlib runCatching
□ _state.update { } used for derived updates, not value =
TESTING
□ Reducer tested with no coroutine harness at all
□ ViewModel tested with runTest + Turbine
□ Independent section behavior explicitly asserted
□ Effects asserted separately from state

Conclusion

MVI maps onto coroutines cleanly: state is a StateFlow, intents are a Channel, effects are a Channel, and the reducer is a pure function. That much is the easy part and every article covers it.

The part that matters in production is state modelling. A dashboard with six independently-loading sections cannot be represented by one boolean and one nullable string, and forcing it produces screens that hide working content behind an error from a marketing endpoint. SectionState<T> per section, one Job per section, and a generic SectionContainer composable solve it in about forty lines and scale to any number of sections.

The takeaways:

  1. Four types per screen — State, Intent, Effect, ViewModel
  2. The intent channel serializes user actions and gives you one place to instrument
  3. Keep the reducer pure — it becomes trivially testable with zero harness
  4. Effects belong in a Channel, never in StateFlow (Part 6's rule)
  5. **SectionState<T> per section** beats a monolithic isLoading/error
  6. One Job per section so retrying one doesn't cancel the others
  7. **SavedStateHandle.getStateFlow** bridges MVI and process death
  8. Content composables take (state, onIntent) — previewable and testable
  9. **Channel.UNLIMITED for intents** so user actions are never dropped
  10. A generic SectionContainer handles loading/error/retry for every section

In Part 11 we cover testing properly — runTest and virtual time, StandardTestDispatcher vs UnconfinedTestDispatcher, Dispatchers.setMain, Turbine for flow assertions, testing cancellation and timeouts, and how to write coroutine tests that are fast and never flaky.

What’s Next

Part 11: Testing Coroutines & Flows — runTest and Virtual Time, StandardTestDispatcher vs UnconfinedTestDispatcher, MainDispatcherRule, Turbine, Testing Cancellation and Timeouts, Fakes vs Mocks, and Eliminating Flaky Coroutine Tests for Good.

Connect with Me on LinkedIn

Follow me on LinkedIn

Tags: #Kotlin #MVI #Coroutines #KotlinFlow #AndroidDev #AndroidArchitecture #StateManagement #JetpackCompose


메타데이터
post_id
8f170f76bc2b
slug
kotlin-coroutines-part-10-mvi-with-coroutines-one-state-flow-an-intent-channel-a-pure-reducer-8f170f76bc2b
url
https://medium.com/@ramadan123sayed/kotlin-coroutines-part-10-mvi-with-coroutines-one-state-flow-an-intent-channel-a-pure-reducer-8f170f76bc2b
canonical_url
https://medium.com/@ramadan123sayed/kotlin-coroutines-part-10-mvi-with-coroutines-one-state-flow-an-intent-channel-a-pure-reducer-8f170f76bc2b
author_url
https://medium.com/@ramadan123sayed
status
ok
fetched_at
2026-08-17 03:27:33