| name | mvi-editor |
| description | Implements the MVI (Model-View-Intent) pattern for Android with Jetpack Compose, library-free. Use when user asks to "create a new screen", "add a ViewModel", "implement MVI", "add state management", or "set up unidirectional data flow". |
MVI Editor
Overview
MVI (Model-View-Intent) is a unidirectional data flow pattern for Android UIs:
User Action → Event → ViewModel → State / SideEffect → UI
This skill is fully self-contained. Everything you need is defined here — no external MVI library required. Copy the foundation once and reuse it across all features.
Data Flow
┌──────────────────────────────────────────────────┐
│ UI Layer │
│ │
│ ┌──────────┐ events ┌─────────────────┐ │
│ │ Screen │ ────────► │ ViewModel │ │
│ │ │ │ │ │
│ │ │ ◄──────── │ _state │ │
│ │ │ state │ _sideEffects │ │
│ └──────────┘ └─────────────────┘ │
│ │ │ │
│ side effects domain calls │
│ ▼ ▼ │
│ Navigation / Use Cases / │
│ Toast / Dialog Repositories │
└──────────────────────────────────────────────────┘
Step 1 — Foundation (copy once per project)
Create this base class in a shared module. It encapsulates the boilerplate that every ViewModel would otherwise duplicate:
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
abstract class MviViewModel<S : Any, E : Any, F : Any>(
initialState: S,
) : ViewModel() {
private val _state = MutableStateFlow(initialState)
val state: StateFlow<S> = _state.asStateFlow()
private val _sideEffects = Channel<F>(Channel.BUFFERED)
val sideEffects: Flow<F> = _sideEffects.receiveAsFlow()
abstract fun onEvent(event: E)
protected fun updateState(reducer: S.() -> S) {
_state.update { it.reducer() }
}
protected fun emitEffect(effect: F) {
viewModelScope.launch { _sideEffects.send(effect) }
}
}
No base class preferred? Skip MviViewModel and copy the _state + _sideEffects boilerplate directly into each ViewModel — see the "Without base class" example below.
Step 2 — Contract (one file per feature)
Three plain Kotlin types — no external interfaces to extend:
data class OrderViewState(
val isLoading: Boolean = false,
val orders: List<Order> = emptyList(),
val errorMessage: String? = null,
)
sealed interface OrderEvent {
data object OnScreenOpened : OrderEvent
data object OnRefreshClicked : OrderEvent
data class OnOrderClicked(val orderId: String) : OrderEvent
}
sealed interface OrderEffect {
data class ShowError(val message: String) : OrderEffect
sealed interface Navigation : OrderEffect {
data class GoToDetail(val orderId: String) : Navigation
data object GoBack : Navigation
}
}
Contract rules
| Type | Kotlin type | Rule |
|---|
| State | data class | All fields val, all have defaults |
| Event | sealed interface | data object for parameterless, data class for carrying data |
| SideEffect | sealed interface | Nest Navigation as sub-sealed for type-safe nav wiring |
Step 3 — ViewModel
With MviViewModel base class
class OrderViewModel(
private val getOrdersUseCase: GetOrdersUseCase,
) : MviViewModel<OrderViewState, OrderEvent, OrderEffect>(
initialState = OrderViewState(),
) {
override fun onEvent(event: OrderEvent) {
when (event) {
OrderEvent.OnScreenOpened,
OrderEvent.OnRefreshClicked -> loadOrders()
is OrderEvent.OnOrderClicked -> navigateToDetail(event.orderId)
}
}
private fun loadOrders() {
viewModelScope.launch {
updateState { copy(isLoading = true, errorMessage = null) }
getOrdersUseCase()
.onSuccess { orders ->
updateState { copy(isLoading = false, orders = orders) }
}
.onFailure { error ->
updateState { copy(isLoading = false) }
emitEffect(OrderEffect.ShowError(error.message ?: "Unknown error"))
}
}
}
private fun navigateToDetail(orderId: String) {
emitEffect(OrderEffect.Navigation.GoToDetail(orderId))
}
}
Without base class (flat)
class OrderViewModel(
private val getOrdersUseCase: GetOrdersUseCase,
) : ViewModel() {
private val _state = MutableStateFlow(OrderViewState())
val state: StateFlow<OrderViewState> = _state.asStateFlow()
private val _sideEffects = Channel<OrderEffect>(Channel.BUFFERED)
val sideEffects: Flow<OrderEffect> = _sideEffects.receiveAsFlow()
fun onEvent(event: OrderEvent) {
when (event) {
OrderEvent.OnScreenOpened,
OrderEvent.OnRefreshClicked -> loadOrders()
is OrderEvent.OnOrderClicked -> navigateToDetail(event.orderId)
}
}
private fun loadOrders() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, errorMessage = null) }
getOrdersUseCase()
.onSuccess { orders ->
_state.update { it.copy(isLoading = false, orders = orders) }
}
.onFailure { error ->
_state.update { it.copy(isLoading = false) }
_sideEffects.send(OrderEffect.ShowError(error.message ?: "Unknown error"))
}
}
}
private fun navigateToDetail(orderId: String) {
viewModelScope.launch { _sideEffects.send(OrderEffect.Navigation.GoToDetail(orderId)) }
}
}
Step 4 — Screen
@Composable
fun OrderScreen(
state: OrderViewState,
onEvent: (OrderEvent) -> Unit,
sideEffects: Flow<OrderEffect>,
onNavigation: (OrderEffect.Navigation) -> Unit,
) {
LaunchedEffect(Unit) {
sideEffects.collect { effect ->
when (effect) {
is OrderEffect.Navigation -> onNavigation(effect)
is OrderEffect.ShowError -> { }
}
}
}
LaunchedEffect(Unit) {
onEvent(OrderEvent.OnScreenOpened)
}
Box(modifier = Modifier.fillMaxSize()) {
when {
state.isLoading ->
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
state.errorMessage != null ->
ErrorView(
message = state.errorMessage,
onRetry = { onEvent(OrderEvent.OnRefreshClicked) },
)
else ->
OrderList(
orders = state.orders,
onOrderClick = { onEvent(OrderEvent.OnOrderClicked(it.id)) },
)
}
}
}
Screen rules
- Receives
state, onEvent, sideEffects, onNavigation — never the ViewModel directly
- Stateless and pure: same inputs → same UI output
- No business logic, no coroutines launched manually
Step 5 — Navigation wiring (app layer)
fun NavGraphBuilder.orderNavigation(navController: NavController) {
composable<OrderRoute> {
val viewModel: OrderViewModel = viewModel()
val state by viewModel.state.collectAsStateWithLifecycle()
OrderScreen(
state = state,
onEvent = viewModel::onEvent,
sideEffects = viewModel.sideEffects,
onNavigation = { nav ->
when (nav) {
is OrderEffect.Navigation.GoToDetail ->
navController.navigate(OrderDetailRoute(nav.orderId))
OrderEffect.Navigation.GoBack ->
navController.popBackStack()
}
},
)
}
}
Common State Patterns
Loading / Error / Content
data class ContentViewState(
val isLoading: Boolean = false,
val content: ContentData? = null,
val error: String? = null,
)
when {
state.isLoading -> LoadingIndicator()
state.error != null -> ErrorView(state.error)
state.content != null -> ContentView(state.content)
}
Form
data class FormViewState(
val email: String = "",
val password: String = "",
val isSubmitting: Boolean = false,
val emailError: String? = null,
) {
val isSubmitEnabled: Boolean
get() = email.isNotBlank() && password.isNotBlank() && !isSubmitting
}
Paginated list
data class ListViewState(
val items: List<Item> = emptyList(),
val isLoadingMore: Boolean = false,
val hasNextPage: Boolean = true,
val currentPage: Int = 0,
)
Testing
@OptIn(ExperimentalCoroutinesApi::class)
class OrderViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val getOrdersUseCase: GetOrdersUseCase = mockk()
private lateinit var sut: OrderViewModel
@Before
fun setup() {
sut = OrderViewModel(getOrdersUseCase)
}
@Test
fun `SHOULD show orders WHEN load succeeds`() = runTest {
val orders = listOf(Order("1", "Pizza"))
coEvery { getOrdersUseCase() } returns Result.success(orders)
sut.onEvent(OrderEvent.OnScreenOpened)
advanceUntilIdle()
assertEquals(orders, sut.state.value.orders)
assertFalse(sut.state.value.isLoading)
assertNull(sut.state.value.errorMessage)
}
@Test
fun `SHOULD emit ShowError WHEN load fails`() = runTest {
coEvery { getOrdersUseCase() } returns Result.failure(RuntimeException("Net error"))
val effects = mutableListOf<OrderEffect>()
val job = launch { sut.sideEffects.toList(effects) }
sut.onEvent(OrderEvent.OnScreenOpened)
advanceUntilIdle()
job.cancel()
assertTrue(effects.first() is OrderEffect.ShowError)
}
}
class MainDispatcherRule(
val dispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher(),
) : TestWatcher() {
override fun starting(description: Description?) = Dispatchers.setMain(dispatcher)
override fun finished(description: Description?) = Dispatchers.resetMain()
}
Anti-Patterns
| ❌ Bad | ✅ Good |
|---|
ViewModel injected directly into @Composable | Pass state, onEvent, sideEffects as params |
MutableStateFlow or Channel exposed as public | Expose StateFlow / Flow (read-only) |
| Navigation logic inside a composable | Emit Navigation effect, handle in nav layer |
var fields in the state | All fields val, use copy() to update |
Multiple StateFlow fields in ViewModel | Single state: StateFlow<ViewState> |
| Coroutine launched without a scope | Always use viewModelScope.launch |
GlobalScope | Never — leaks beyond the lifecycle |
References