kmp-presentation-mvi
Skill18 starsChanged 4 months agoMITView on GitHub
MVI presentation layer for Kotlin Multiplatform (Android + iOS) - State, Action, Event, ViewModel, Root/Screen 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", "action", "event", "screen composable", "UiText", "SavedStateHandle", "ObserveAsEvents", or "UI model".
In this repository67 other files it gives its agents.
- agent-onboardingSkill
- android-backgroundSkill
- android-cliSkill
- android-compose-architectureSkill
- android-compose-componentsSkill
- android-data-layerSkill
- android-di-koinSkill
- android-error-handlingSkill
- android-integration-testingSkill
- android-jacoco-setupSkill
- android-jvm-test-coverageSkill
- android-module-structureSkill
- android-navigationSkill
- android-presentation-mviSkill
- android-preview-figma-verifySkill
- android-ui-screenshot-verifySkill
- android-ui-testingSkill
- android-ui-verifySkill
- android-unit-testingSkill
- android-version-catalogSkill
- architecture-decision-recordSkill
- careful-planningSkill
- compose-adaptive-layoutsSkill
- figma-to-android-vectorSkill
- finalizeSkill
- git-commitSkill
- handoffSkill
- kmp-compose-architectureSkill
- kmp-compose-componentsSkill
- kmp-data-layerSkill
- kmp-di-koinSkill
- kmp-error-handlingSkill
- kmp-integration-testingSkill
- kmp-migration-analyzerSkill
- kmp-module-structureSkill
- kmp-navigationSkill
- kmp-ui-testingSkill
- kmp-unit-testingSkill
- kotlin-coroutinesSkill
- kotlin-flowsSkill
- kotlin-mutation-testingSkill
- openspec-artifact-review-lightSkill
- openspec-artifact-reviewSkill
- project-breakdownSkill
- react-data-layerSkill
- react-error-handlingSkill
- react-integration-testingSkill
- react-presentation-mviSkill
- react-project-structureSkill
- react-routingSkill
- react-screen-architectureSkill
- react-securitySkill
- react-server-stateSkill
- react-ui-componentsSkill
- react-unit-testingSkill
- speckit-parallelize-agent-teamsSkill
- speckit-parallelize-subagentsSkill
- spring-api-layerSkill
- spring-di-configurationSkill
- spring-integration-testingSkill
- The first 60 shown.
The file
We split this skill into the rules it gives an agent
9 rules. About 0 are common, 4 are distinctive, and 5 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈4
Distinctive · estimate
This author's own choices. Worth a look.
5
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 23
Never add androidx.lifecycle:lifecycle-*-compose to commonMain — those coordinates are Android-only.
- Very specific: line 45
Always update state with .update { } — never replace the entire flow:
- Distinctive: line 70
Add an event for a case only the ViewModel can decide, such as:
- Distinctive: line 77
Do not add an event for anything the UI can decide on its own from a click or from state:
- Very specific: line 184
Only inject CoroutineDispatcher when:
- Distinctive: line 240
When a domain model needs UI-specific formatting (dates, units, currency), create a dedicated UI model in the presentation layer:
- Very specific: line 337
When a screen involves complex forms or critical user input, restore essential fields using SavedStateHandle:
- Distinctive: line 362
Only save what truly matters after process death — not the entire state.
- Very specific: line 368
Only store values that can be saved on both platforms: primitives, String, and lists/arrays of those.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
KMP Presentation Layer (MVI)
Everything in this layer lives in commonMain and is shared between Android and iOS.
Dependencies
ViewModel, viewModelScope, SavedStateHandle, and collectAsStateWithLifecycle are multiplatform, but must come from the JetBrains artifacts. The Kotlin imports stay androidx.lifecycle.*. The cmp.feature convention plugin adds all of them (see kmp-module-structure).
| Concern | Artifact (commonMain) |
ViewModel, viewModelScope | org.jetbrains.androidx.lifecycle:lifecycle-viewmodel |
| ViewModel in Compose | org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose |
collectAsStateWithLifecycle, repeatOnLifecycle | org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose |
SavedStateHandle | org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate |
koinViewModel() | io.insert-koin:koin-compose-viewmodel (import org.koin.compose.viewmodel.koinViewModel) |
Never add androidx.lifecycle:lifecycle-*-compose to commonMain — those coordinates are Android-only.
Overview
Every screen has:
- State — a single data class holding all UI state fields.
- Action (Intent) — a sealed interface of all user-triggered actions.
- ViewModel — holds
StateFlow<State>and processesAction. - Event (optional) — a sealed interface of one-time side effects the ViewModel needs to push to the UI. Only add this when needed (see When to add Events).
State
data class NoteListState(
val notes: List<NoteUi> = emptyList(),
val isLoading: Boolean = false,
val error: UiText? = null
)Always update state with .update { } — never replace the entire flow:
_state.update { it.copy(isLoading = true) }Action (Intent)
sealed interface NoteListAction {
data object OnRefreshClick : NoteListAction
data class OnNoteClick(val noteId: String) : NoteListAction
data class OnDeleteNote(val noteId: String) : NoteListAction
}Event (one-time side effects, optional)
Events exist only when the ViewModel needs to push a one-time side effect to the UI that only the ViewModel knows about. If no such side effect exists for a screen, do not define an Event type, do not create a Channel, and do not expose an events Flow.
When to add an Event
Add an event for a case only the ViewModel can decide, such as:
- Navigating after a successful async operation (e.g., navigate to home after a successful login call completes).
- Showing a snackbar because of a network error that came back from a repository call.
- Triggering a platform side effect after business logic finishes (e.g., copy to clipboard, share sheet, biometric prompt).
When NOT to add an Event
Do not add an event for anything the UI can decide on its own from a click or from state:
- Plain navigation triggered directly by a button click that doesn't need a ViewModel result — navigate directly from the composable's
onClick, without a roundtrip throughonActionand an event. - Showing a dialog or bottom sheet whose visibility can live in
Stateas a boolean or nullable value. - Any UI reaction that can be derived from observing
Statechanges.
If none of the above cases apply to the screen, drop the Event sealed interface, the Channel, and the events Flow entirely. The ObserveAsEvents block in the Root composable is also removed.
Example (when events ARE needed)
sealed interface LoginEvent {
data object LoginSucceeded : LoginEvent
data class ShowSnackbar(val message: UiText) : LoginEvent
}Here LoginSucceeded can only be known after the async login call resolves, and the network error can only be turned into a snackbar message inside the ViewModel — both are legitimate events.
ViewModel
With events (only when actually needed)
class LoginViewModel(
private val authRepository: AuthRepository
) : ViewModel() {
private val _state = MutableStateFlow(LoginState())
val state = _state.asStateFlow()
private val _events = Channel<LoginEvent>()
val events = _events.receiveAsFlow()
fun onAction(action: LoginAction) {
when (action) {
is LoginAction.OnLoginClick -> login()
/* ... */
}
}
private fun login() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
authRepository.login(state.value.email, state.value.password)
.onSuccess {
_state.update { it.copy(isLoading = false) }
_events.send(LoginEvent.LoginSucceeded)
}
.onFailure { error ->
_state.update { it.copy(isLoading = false) }
_events.send(LoginEvent.ShowSnackbar(error.toUiText()))
}
}
}
}Without events (default when nothing needs to be pushed)
class NoteListViewModel(
private val noteRepository: NoteRepository
) : ViewModel() {
private val _state = MutableStateFlow(NoteListState())
val state = _state.asStateFlow()
fun onAction(action: NoteListAction) {
when (action) {
is NoteListAction.OnRefreshClick -> loadNotes()
/* OnNoteClick is NOT handled here — the composable navigates directly on click */
}
}
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, error = error.toUiText()) }
}
}
}
}Note how the error surfaces via State.error instead of an event — observing state is enough because the UI just needs to render it.
Coroutine Dispatchers
Do not inject unless the class is unit-tested and dispatches to a non-main dispatcher. For ViewModel tests, use Dispatchers.setMain(UnconfinedTestDispatcher()) in test setup.
For blocking code that doesn't support suspension, wrap it:
// import kotlinx.coroutines.IO ← required for Dispatchers.IO in common code
suspend fun compressImage(bytes: ByteArray): ByteArray = withContext(Dispatchers.IO) {
// blocking compression logic
}Only inject CoroutineDispatcher when:
- The class dispatches to a non-main dispatcher (e.g.,
IO), AND - That class is directly unit-tested.
Mapping Errors to UI Strings
UiText (core:presentation) wraps strings that originate from — or could originate from — a string resource:
// import org.jetbrains.compose.resources.StringResource
// import org.jetbrains.compose.resources.stringResource
// import org.jetbrains.compose.resources.getString
sealed interface UiText {
data class DynamicString(val value: String) : UiText
class Resource(val id: StringResource, val args: Array<Any> = emptyArray()) : UiText
@Composable
fun asString(): String = when (this) {
is DynamicString -> value
is Resource -> stringResource(id, *args)
}
suspend fun asStringAsync(): String = when (this) {
is DynamicString -> value
is Resource -> getString(id, *args)
}
}There are no Int resource IDs in shared code. The ID is a Compose Multiplatform StringResource (Res.string.*), and the variant is named UiText.Resource so it does not clash with that library type. Use asString() inside composables and asStringAsync() outside composition (e.g. when showing a snackbar from an event).
When to use UiText: For any string that comes from a string resource, could be localized, or might be either a resource or a dynamic value depending on context (e.g., error messages that map to Res.string.*).
When to use plain String: For values that are always dynamic and never come from resources — e.g., a user's name, a formatted date, a currency amount. These should be exposed as String directly in the state or UI model.
// UiText — error message that maps to a string resource
data class NoteListState(
val error: UiText? = null
)
// Plain String — always dynamic, never a resource
data class NoteUi(
val authorName: String,
val formattedDate: String
)Define DataError.toUiText() extension functions in core:presentation (or feature presentation) that map error enums to UiText.Resource.
UI Model (Presentation Model)
When a domain model needs UI-specific formatting (dates, units, currency), create a dedicated UI model in the presentation layer:
data class NoteUi(
val id: String,
val title: String,
val formattedDate: String // e.g. "Mar 15, 2026"
)
fun Note.toNoteUi(): NoteUi = NoteUi(
id = id,
title = title,
formattedDate = date.format(...) // kotlinx-datetime formatting — java.time / SimpleDateFormat don't exist in commonMain
)UI models are always suffixed with Ui (e.g., NoteUi, TodoItemUi).
Composable Structure
Both the Root and Screen composable live in the same file (e.g., NoteListScreen.kt).
Root Composable (suffixed Root)
Receives the ViewModel (via koinViewModel()) and any callbacks needed for navigation. Observes events only when the ViewModel exposes an events Flow. Passes state and onAction down.
For plain navigation that a button click triggers directly, invoke the navigation callback from the Screen composable's onClick — do not route it through onAction and an event.
Screen Composable (suffixed Screen)
Receives state, onAction, and any direct navigation callbacks that don't need a ViewModel roundtrip. No ViewModel reference. Can be previewed independently — the @Preview lives in commonMain in the same file.
Example — no events (navigation happens directly on click)
// NoteListScreen.kt — Root + Screen in a single file
@Composable
fun NoteListRoot(
onNavigateToDetail: (String) -> Unit,
viewModel: NoteListViewModel = koinViewModel()
) {
val state by viewModel.state.collectAsStateWithLifecycle()
NoteListScreen(
state = state,
onAction = viewModel::onAction,
onNoteClick = onNavigateToDetail
)
}
@Composable
fun NoteListScreen(
state: NoteListState,
onAction: (NoteListAction) -> Unit,
onNoteClick: (String) -> Unit
) { /* onNoteClick fires directly from the list item's onClick */ }
@Preview
@Composable
private fun NoteListScreenPreview() {
NoteListScreen(state = NoteListState(), onAction = {}, onNoteClick = {})
}Example — with events (one-time side effect only the ViewModel knows)
// LoginScreen.kt
@Composable
fun LoginRoot(
onLoginSuccess: () -> Unit,
viewModel: LoginViewModel = koinViewModel()
) {
val state by viewModel.state.collectAsStateWithLifecycle()
ObserveAsEvents(viewModel.events) { event ->
when (event) {
LoginEvent.LoginSucceeded -> onLoginSuccess()
is LoginEvent.ShowSnackbar -> { /* show snackbar */ }
}
}
LoginScreen(
state = state,
onAction = viewModel::onAction
)
}Process Death
When a screen involves complex forms or critical user input, restore essential fields using SavedStateHandle:
class NoteEditorViewModel(
private val savedStateHandle: SavedStateHandle,
private val noteRepository: NoteRepository
) : ViewModel() {
private val _state = MutableStateFlow(
NoteEditorState(
title = savedStateHandle["title"] ?: "",
body = savedStateHandle["body"] ?: ""
)
)
fun onAction(action: NoteEditorAction) {
when (action) {
is NoteEditorAction.OnTitleChange -> {
savedStateHandle["title"] = action.title
_state.update { it.copy(title = action.title) }
}
}
}
}Only save what truly matters after process death — not the entire state.
Platform notes:
SavedStateHandlecompiles incommonMainand is injected by Koin'sviewModelOfon both platforms.- On Android it survives process death as usual.
- On iOS nothing is restored automatically after the app is terminated. The handle only lives as long as its nav back stack entry. Anything that must survive an iOS app kill has to be persisted explicitly (DataStore / Room).
- Only store values that can be saved on both platforms: primitives,
String, and lists/arrays of those. NoParcelable— store structured values as a KotlinX-serializedString.
Naming Conventions
| Thing | Convention | Example |
| ViewModel | <Screen>ViewModel | NoteListViewModel |
| State | <Screen>State | NoteListState |
| Action | <Screen>Action | NoteListAction |
| Event (optional) | <Screen>Event | LoginEvent |
| Root composable | <Screen>Root | NoteListRoot |
| Screen composable | <Screen>Screen | NoteListScreen |
| UI model | <Model>Ui | NoteUi, TodoItemUi |
Checklist: Adding a New Screen
- Define
StateandActioninfeature:presentation - Decide whether an
Eventtype is needed (only for one-time side effects the ViewModel alone can determine — see When to add an Event). If not, skip theEventsealed interface, theChannel, and theeventsFlow entirely. - Implement
ViewModelinfeature:presentation(exposeeventsFlow only if events exist) - Create
<Screen>Rootcomposable (holds ViewModel; addsObserveAsEventsonly when events exist) - Create
<Screen>Screencomposable (pure state + onAction + any direct navigation callbacks, previewable) - Route plain button-click navigation directly through composable callbacks — not through
onAction+ event - Map any domain errors to
UiTextvia extension functions - Add
SavedStateHandlefor any form fields that must survive process death
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

