# Mvi Pattern

> Implements MVI (Model-View-Intent) pattern for Android feature development. Use when creating new feature ViewModels, defining ViewState/ViewEvent/ViewEffect, handling data loading with callPagingDataWithInternet or collectDataStateWithInternet, structuring Screen composables with stateful/stateless split, or handling navigation via effects.

- Skill: `thetruong1099/mvi-pattern` (Agent Skill)
- Install (CLI): `npx skillmds@latest add thetruong1099/mvi-pattern`
- Raw SKILL.md: https://api.skillmd.com/api/skills/thetruong1099/mvi-pattern/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: thetruong1099 (https://skillmd.com/u/thetruong1099)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/thetruong1099/mvi-pattern

---


# MVI Pattern

## Overview

```
User Action -> Event -> onTriggerEvent() -> setState{} / setEffect() -> UI Update
```

## Core Contracts

```kotlin
interface IViewState      // Marker for UI state (data class)
interface IViewEvent      // Marker for user actions (sealed interface)
interface IViewEffect     // Marker for one-time side effects (sealed interface)
```

## BaseViewModel API

```kotlin
abstract class BaseViewModel<State : IViewState, Event : IViewEvent, Effect : IViewEffect>
    : ViewModel(), IBaseViewModel<State, Event, Effect>
```

### Abstract Methods (MUST implement)

```kotlin
abstract fun createInitialState(): State
abstract fun onTriggerEvent(event: Event)
```

### State Management

```kotlin
protected fun setState(reduce: State.() -> State)  // Update with reducer
protected val currentState: State                   // Access current state
```

### Effect Management

```kotlin
protected fun setEffect(effect: Effect)  // Trigger one-time effect
fun clearEffect()                        // Clear after consumption
```

### Loading & Pull-to-Refresh

```kotlin
protected fun onLoading(loading: Boolean = true)
protected fun onEnablePullRefresh(enable: Boolean = true)
```

### Toast

```kotlin
protected fun showErrorToast(error: AppError)
protected fun showSuccessToast(messageResId: Int)
protected fun showWarningToast(messageResId: Int)
protected fun showInfoToast(messageResId: Int)
```

### Data Collection Functions

```kotlin
// PagingData (returns Flow - NOT suspend)
protected fun <T : Any> callPagingDataWithInternet(
    callFlow: () -> Flow<PagingData<T>>,
    onError: (AppError) -> Unit = {}
): Flow<PagingData<T>>

// DataState flows (suspend - terminal operation)
protected suspend fun <T> collectDataStateWithInternet(
    callFlow: Flow<DataState<T>>,
    onSuccess: suspend (T) -> Unit,
    onError: suspend (AppError) -> Unit = {},
    showLoading: Boolean = true,
)

// One-shot suspend operations
protected suspend fun <T> callSuspendWithInternet(
    operation: suspend () -> T,
    onSuccess: suspend (T) -> Unit = {},
    onError: suspend (AppError) -> Unit = {},
    showLoading: Boolean = false,
)

// Variants without internet check
protected fun <T : Any> callPagingDataWithoutInternet(...)
protected suspend fun <T> collectDataState(...)
protected suspend fun <T> callSuspendWithoutInternet(...)
```

## Complete Feature Example

### 1. ViewModel

```kotlin
@HiltViewModel
class SampleViewModel @Inject constructor(
    private val getSampleUseCase: GetSampleUseCase,
) : BaseViewModel<SampleViewState, SampleViewEvent, SampleViewEffect>() {

    override fun createInitialState(): SampleViewState = SampleViewState()

    override fun onTriggerEvent(event: SampleViewEvent) {
        when (event) {
            is SampleViewEvent.OnItemClick ->
                setEffect(SampleViewEffect.NavigateToDetail(event.item.id))
            is SampleViewEvent.OnRefresh ->
                loadData()
        }
    }

    init {
        onEnablePullRefresh(true)
        loadData()
    }

    private fun loadData() {
        val result = callPagingDataWithInternet(
            callFlow = { getSampleUseCase() },
            onError = { error -> showErrorToast(error) },
        ).cachedIn(viewModelScope)
        setState { copy(items = result) }
    }
}

data class SampleViewState(
    val items: Flow<PagingData<SampleModel>>? = null,
) : IViewState

sealed interface SampleViewEvent : IViewEvent {
    data class OnItemClick(val item: SampleModel) : SampleViewEvent
    data object OnRefresh : SampleViewEvent
}

sealed interface SampleViewEffect : IViewEffect {
    data class NavigateToDetail(val id: String) : SampleViewEffect
}
```

### 2. Screen

```kotlin
// Stateful: handles DI, navigation, effects
@Composable
fun SampleScreen(viewModel: SampleViewModel = hiltViewModel()) {
    val navController = LocalAppNavController.current

    LaunchedEffect(Unit) {
        viewModel.effectState.collect { effect ->
            when (effect) {
                is SampleViewEffect.NavigateToDetail -> {
                    navController.navigateTo(Screen.DetailScreen(id = effect.id))
                    viewModel.clearEffect()
                }
                null -> {}
            }
        }
    }

    SampleScreenInternal(
        viewModel = viewModel,
        onItemClick = { item -> viewModel.onTriggerEvent(SampleViewEvent.OnItemClick(item)) },
    )
}

// Stateless: previewable, uses IBaseViewModel interface
@Composable
internal fun SampleScreenInternal(
    viewModel: IBaseViewModel<SampleViewState, SampleViewEvent, SampleViewEffect>,
    modifier: Modifier = Modifier,
    onItemClick: (SampleModel) -> Unit = {},
) {
    BaseScreenComponent(
        viewModel = viewModel,
        modifier = modifier.fillMaxSize(),
    ) { state, paddingValues ->
        // UI content using state
    }
}
```

### 3. Navigation

```kotlin
fun NavGraphBuilder.sampleScreen() {
    composable<Screen.SampleScreen> { SampleScreen() }
}
```

### 4. Preview

```kotlin
@Preview(showBackground = true)
@Composable
private fun SampleScreenPreview() {
    val fakeViewModel = FakeBaseViewModel<SampleViewState, SampleViewEvent, SampleViewEffect>(
        initialState = SampleViewState()
    )
    TemplateThemePreview(darkTheme = false) {
        SampleScreenInternal(viewModel = fakeViewModel)
    }
}
```

## CORRECT vs INCORRECT

```kotlin
// State: use setState with copy()
setState { copy(isLoading = true, items = newItems) }       // CORRECT
_uiState.value = currentState.copy(isLoading = true)        // INCORRECT

// Events: all user actions through events
viewModel.onTriggerEvent(SampleViewEvent.OnRefresh)          // CORRECT
viewModel.refresh()                                          // INCORRECT - don't expose methods

// Effects: navigation through effects, not state
setEffect(MyEffect.NavigateToDetail(event.id))               // CORRECT
setState { copy(navigateTo = "detail") }                     // INCORRECT

// PagingData: callPagingDataWithInternet returns Flow (NOT suspend)
val result = callPagingDataWithInternet(callFlow = { useCase() }, ...).cachedIn(viewModelScope)
viewModelScope.launch { callPagingDataWithInternet(...) }    // INCORRECT - don't launch Flow

// DataState: suspend, launch in viewModelScope
viewModelScope.launch { collectDataStateWithInternet(...) }  // CORRECT
```

## Composition Managers (Internal)

| Manager                 | Exposed via                                      |
|-------------------------|--------------------------------------------------|
| `StateManager`          | `setState {}`, `currentState`                    |
| `EffectManager`         | `setEffect()`, `clearEffect()`                   |
| `LoadingStateManager`   | `onLoading()`, `onEnablePullRefresh()`           |
| `ToastManager`          | `showErrorToast()`, `showSuccessToast()`, etc.   |
| `FlowCollectionManager` | `callPagingData*()`, `collectDataState*()`, etc. |

