MVI Pattern
Overview
User Action -> Event -> onTriggerEvent() -> setState{} / setEffect() -> UI Update
Core Contracts
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
abstract class BaseViewModel<State : IViewState, Event : IViewEvent, Effect : IViewEffect>
: ViewModel(), IBaseViewModel<State, Event, Effect>
Abstract Methods (MUST implement)
abstract fun createInitialState(): State
abstract fun onTriggerEvent(event: Event)
State Management
protected fun setState(reduce: State.() -> State) // Update with reducer
protected val currentState: State // Access current state
Effect Management
protected fun setEffect(effect: Effect) // Trigger one-time effect
fun clearEffect() // Clear after consumption
Loading & Pull-to-Refresh
protected fun onLoading(loading: Boolean = true)
protected fun onEnablePullRefresh(enable: Boolean = true)
Toast
protected fun showErrorToast(error: AppError)
protected fun showSuccessToast(messageResId: Int)
protected fun showWarningToast(messageResId: Int)
protected fun showInfoToast(messageResId: Int)
Data Collection Functions
// 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
@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() },
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
// 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,
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
fun NavGraphBuilder.sampleScreen() {
composable<Screen.SampleScreen> { SampleScreen() }
}
4. Preview
@Preview(showBackground = true)
@Composable
private fun SampleScreenPreview() {
val fakeViewModel = FakeBaseViewModel<SampleViewState, SampleViewEvent, SampleViewEffect>(
initialState = SampleViewState()
)
TemplateThemePreview(darkTheme = false) {
SampleScreenInternal(viewModel = fakeViewModel)
}
}
CORRECT vs INCORRECT
// 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. |