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…
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:
- Serialized processing. Intents are handled one at a time, in order, on a single coroutine. No interleaving, no races between two rapid taps.
- A single funnel to instrument. One
collectto log every user action, replay in tests, or feed to analytics. **trySendis non-suspending.** Safe to call from any callback —onClick, a gesture handler, aBroadcastReceiver— 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.
isLoadingstays 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).
erroris 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:
- Four types per screen — State, Intent, Effect, ViewModel
- The intent channel serializes user actions and gives you one place to instrument
- Keep the reducer pure — it becomes trivially testable with zero harness
- Effects belong in a
Channel, never inStateFlow(Part 6's rule) **SectionState<T>per section** beats a monolithicisLoading/error- One
Jobper section so retrying one doesn't cancel the others **SavedStateHandle.getStateFlow** bridges MVI and process death- Content composables take
(state, onIntent)— previewable and testable **Channel.UNLIMITEDfor intents** so user actions are never dropped- A generic
SectionContainerhandles 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