MVI presentation layer for Android/KMP - State, Action/ViewIntent, Event/ViewSideEffect, ViewModel, Screen/Content composable split, UI models, UiText error mapping, and process death with SavedStateHandle. Use this skill whenever creating or reviewing a ViewModel, defining screen state, actions, or events, structuring composables, mapping errors to UI strings, or handling process death. Trigger on phrases like "add a ViewModel", "create a screen", "MVI", "state", "ViewIntent", "Action", "ViewSideEffect", "Event", "screen composable", "UiText", "SavedStateHandle", "ObserveAsEvents", "UI model", "hiltViewModel", "koinViewModel", "BaseViewModel".
Before writing any code, check the project dependencies:
hilt → use @HiltViewModel, hiltViewModel()koin → use koinViewModel()Check the project for existing ViewModel base classes:
ViewIntent or BaseViewModel<S, I, E> → use ViewIntentAction pattern → use ActionCheck the project for existing patterns:
ViewSideEffect → use ViewSideEffectEvent → use EventChoose the State structure based on the screen's UI:
data class NoteEditorState(
val title: String = "",
val body: String = "",
val isSaving: Boolean = false,
val titleError: UiText? = null
)
sealed interface NoteListState {
data object Loading : NoteListState
data class Content(val notes: List<NoteUi>) : NoteListState
data class Error(val message: UiText) : NoteListState
}
sealed interface NoteListState {
data object Loading : NoteListState
data class Content(
val notes: List<NoteUi>,
val isRefreshing: Boolean = false,
val searchQuery: String = ""
) : NoteListState
data class Error(val message: UiText) : NoteListState
}
Decision rule: Look at the UI — does the screen fully change between states?
Always update state with .update { } — never replace the entire flow:
_state.update { it.copy(isLoading = true) }
sealed interface NoteListIntent {
data object OnRefreshClick : NoteListIntent
data class OnNoteClick(val noteId: String) : NoteListIntent
data class OnDeleteNote(val noteId: String) : NoteListIntent
}
sealed interface NoteListSideEffect {
data class NavigateToDetail(val noteId: String) : NoteListSideEffect
data class ShowSnackbar(val message: UiText) : NoteListSideEffect
}
@HiltViewModel // or no annotation for Koin
class NoteListViewModel(
private val noteRepository: NoteRepository
) : ViewModel() {
private val _state = MutableStateFlow(NoteListState())
val state = _state.asStateFlow()
private val _sideEffects = Channel<NoteListSideEffect>()
val sideEffects = _sideEffects.receiveAsFlow()
fun onIntent(intent: NoteListIntent) {
when (intent) {
is NoteListIntent.OnRefreshClick -> loadNotes()
is NoteListIntent.OnNoteClick -> {
viewModelScope.launch {
_sideEffects.send(NoteListSideEffect.NavigateToDetail(intent.noteId))
}
}
}
}
private fun loadNotes() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
noteRepository.getNotes()
.onSuccess { notes ->
_state.update { it.copy(notes = notes.map { it.toNoteUi() }, isLoading = false) }
}
.onFailure { error ->
_state.update { it.copy(isLoading = false) }
_sideEffects.send(NoteListSideEffect.ShowSnackbar(error.toUiText()))
}
}
}
}
Do not inject unless the class is unit-tested and dispatches to a non-main dispatcher.
suspend fun compressImage(bytes: ByteArray): ByteArray = withContext(Dispatchers.IO) {
// blocking compression logic
}
Only inject CoroutineDispatcher when:
IO), ANDUiText wraps strings from string resources or dynamic values:
sealed interface UiText {
data class DynamicString(val value: String) : UiText
class StringResource(val id: Int, val args: Array<Any> = emptyArray()) : UiText
}
UiText for error messages that map to R.string.*String for always-dynamic values (user name, formatted date)data class NoteListState(val error: UiText? = null)
data class NoteUi(val authorName: String, val formattedDate: String)
data class NoteUi(
val id: String,
val title: String,
val formattedDate: String
)
fun Note.toNoteUi(): NoteUi = NoteUi(
id = id,
title = title,
formattedDate = date.format(...)
)
UI models are always suffixed with Ui.
Receives ViewModel (via hiltViewModel() or koinViewModel()), observes side effects, passes state and onIntent down.
Receives only state and onIntent. No ViewModel reference. Can be previewed independently.
// NoteListScreen.kt — stateful
@Composable
fun NoteListScreen(
onNavigateToDetail: (String) -> Unit,
viewModel: NoteListViewModel = hiltViewModel() // or koinViewModel()
) {
val state by viewModel.state.collectAsStateWithLifecycle()
ObserveAsEvents(viewModel.sideEffects) { effect ->
when (effect) {
is NoteListSideEffect.NavigateToDetail -> onNavigateToDetail(effect.noteId)
is NoteListSideEffect.ShowSnackbar -> { /* show snackbar */ }
}
}
NoteListContent(
state = state,
onIntent = viewModel::onIntent
)
}
// NoteListContent.kt — stateless
@Composable
fun NoteListContent(
state: NoteListState,
onIntent: (NoteListIntent) -> Unit
) { ... }
@Preview
@Composable
private fun NoteListContentPreview() {
AppTheme {
NoteListContent(state = NoteListState(), onIntent = {})
}
}
:core module, grouped by type (buttons/, inputs/, cards/)feature/ui/<screen>/components/ subpackage — no exceptions, regardless of countUse SavedStateHandle only for forms with user input:
@HiltViewModel
class NoteEditorViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
private val noteRepository: NoteRepository
) : ViewModel() {
private val _state = MutableStateFlow(
NoteEditorState(
title = savedStateHandle["title"] ?: "",
body = savedStateHandle["body"] ?: ""
)
)
fun onIntent(intent: NoteEditorIntent) {
when (intent) {
is NoteEditorIntent.OnTitleChange -> {
savedStateHandle["title"] = intent.title
_state.update { it.copy(title = intent.title) }
}
}
}
}
Only save what truly matters after process death — not the entire state.
| Thing | Convention | Example |
|---|---|---|
| ViewModel | <Screen>ViewModel | NoteListViewModel |
| State | <Screen>State | NoteListState |
| Action/Intent | <Screen>Intent or <Screen>Action | NoteListIntent |
| Event/SideEffect | <Screen>SideEffect or <Screen>Event | NoteListSideEffect |
| Screen composable | <Screen>Screen (stateful) | NoteListScreen |
| Content composable | <Screen>Content (stateless) | NoteListContent |
| UI model | <Model>Ui | NoteUi |
State, Intent/Action, SideEffect/EventViewModel<Screen>Screen.kt (stateful — holds ViewModel, observes side effects)<Screen>Content.kt (stateless — pure state + onIntent, previewable)UiTextSavedStateHandle for form fields that must survive process death