MVI
SkillAI & modelsHow to implement MVI on the KMP Starter Template — State, Actions, Events, and the MviViewModel base class (initialState, onAction, onStateStart, emitEvent/emitEventAsync).
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the MVI skill
What this skill tells your AI
The instructions your AI receives, as published by devatrii/kmp-starter-template in .agents/skills/kmp-starter/mvi/SKILL.md and read by ahel’s review.
Every ViewModel extends Starter's MviViewModel<State, Action, Event> from:
com.kmpstarter.ui_utils.viewmodels.MviViewModel
Do not write plain ViewModel classes. Always subclass MviViewModel. Each ViewModel consists of State, Actions, Events. Keep terminology exact — no Intent/Effect/Mutation.
Shape
data class HomeState(
val name: String = "",
val isLoading: Boolean = false,
)
sealed class HomeAction {
data class EnteringName(val name: String) : HomeAction()
data object SaveButtonClicked : HomeAction()
}
sealed class HomeEvent {
data class ShowSnackbar(val message: String) : HomeEvent()
}
class HomeViewModel : MviViewModel<HomeState, HomeAction, HomeEvent>() {
override val initialState get() = HomeState()
override fun onStateStart() {
loadData()
}
override fun onAction(action: HomeAction) {
when (action) {
is HomeAction.EnteringName -> _state.update { it.copy(name = action.name) }
HomeAction.SaveButtonClicked -> emitEventAsync(HomeEvent.ShowSnackbar("Saved!"))
}
}
}
Key rules
initialStatemust useget():override val initialState get() = State(). A non-get()initializer crashes.- Never read
SavedStateHandleinsideinitialState. Read it ininit {}, then_state.update { ... }. onAction(action)— single entry point for all user interactions;whendispatch here.onStateStart()— lifecycle hook, runs when UI starts collectingstate; load initial data here.- Update state with
_state.update { it.copy(...) }.
Constructor params into state
MviViewModel supports constructor args too — do not switch to a plain ViewModel for parameterized screens.
class NotesViewModel(private val noteId: String) : MviViewModel<NotesState, NotesAction, NotesEvent>() {
override val initialState get() = NotesState()
init {
_state.update { it.copy(noteId = noteId) }
}
}
Pass nav params via parameterOf(...) + koinViewModel { parametersOf(...) } (see koin skill).
Emitting events
Always use the functions provided by MviViewModel:
emitEvent(event)—suspend; use inside an existing coroutine.emitEventAsync(event)— non-suspend; launches inviewModelScope. Prefer for one-offs.emitEventInViewModelis deprecated (renamed toemitEventAsync).
Never hand-roll your own Channel/SharedFlow event stream — MviViewModel.uiEvents already exposes one (SharedFlow, replay = 0).
Observing events in UI
import com.kmpstarter.ui_utils.side_effects.ObserveAsEvents
@Composable
fun HomeScreen(viewModel: HomeViewModel = koinViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
ObserveAsEvents(flow = viewModel.uiEvents) { event ->
when (event) {
is HomeEvent.ShowSnackbar -> SnackbarController.sendMessage(event.message)
}
}
// render state, send actions
}
ObserveAsEvents is lifecycle-aware (won't fire in background); prefer it over LaunchedEffect.
Reference
features/core/presentation/.../viewmodels/OnboardingViewModel.kt is the canonical example (State/Actions/Events, Logics, EventsTracker, emitEvent).
Signals
- GitHub stars
- 162
- Forks
- 28
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
kmp-starter-mvi- Source
- github.com/devatrii/kmp-starter-template