name: kotlin-project-state-management description: Use when choosing, implementing, or reviewing state-holder patterns in a KMP project — ViewModel, shared presenter, MVI, or StateFlow-in-common — including effect handling, UiState modeling, and testability. license: Apache-2.0 metadata: author: Mariano Miani
Use this skill when choosing, implementing, or reviewing how UI state is owned and produced in a Kotlin Multiplatform project.
State management sits at the intersection of every other KMP architectural concern. How state is held, where it lives in source sets, and how it integrates with each platform's lifecycle determines the testability, predictability, and long-term maintainability of the entire UI layer.
This skill is intentionally precise. It distinguishes between patterns clearly, explains when each is appropriate, and flags common mistakes that look correct at first but create problems at scale.
UiState correctlykotlin-navigation-compose-multiplatform)kotlin-data-kmp-data-layer)kotlin-project-architecture-review)kotlin-project-feature-implementation)Regardless of which pattern a project uses, a valid state holder must satisfy this contract:
UiState that the UI renders from. Not several scattered booleans, not a mutable object the UI reads fields from directly.Every pattern described below is evaluated against this contract.
What it is:
ViewModel from AndroidX lifecycle, holding a MutableStateFlow<UiState> privately and exposing it as StateFlow<UiState>. Effects emitted via a separate SharedFlow or Channel-backed flow.
When it is the right choice:
collectAsStateWithLifecycle() or collectAsState() is the rendering layerWhat it provides:
viewModelScope)hiltViewModel(), Navigation ViewModel scoping, SavedStateHandle)Platform constraint:
ViewModel is an AndroidX library. It does not exist natively on iOS, desktop, or web. Projects using ViewModel in shared commonMain code require an additional library to provide a ViewModel-compatible abstraction on non-Android targets (see KMP ViewModel libraries below).
Source-set placement:
androidMaincommonMainTestability:
kotlinx-coroutines-test, TestScope, runTest, and Turbine or collectValues()viewModelScope must be replaced with an injected CoroutineScope in tests, or use the ViewModelScenario/rule pattern from lifecycle-viewmodel-testingWhat it is:
A plain Kotlin class in commonMain that holds a MutableStateFlow<UiState> and exposes it, processes user actions via functions or a sealed Action type, and emits effects. No AndroidX dependency. Lifecycle management is the platform entry point's responsibility.
When it is the right choice:
What it provides:
commonTest with kotlin.test and kotlinx-coroutines-testPlatform responsibility: The presenter owns no lifecycle. Each platform must:
CoroutineScope that is cancelled when the view is goneSavedStateHandle equivalent without explicit implementation)On Android, this usually means creating the presenter inside a ViewModel to retain it across configuration changes, then delegating to it. On iOS, this means creating the presenter in the view owner (e.g., ObservableObject-wrapping in SwiftUI or direct state subscription in UIKit) and tying its scope to the view's lifetime.
Source-set placement:
commonMainandroidMain, iosMain, etc.commonTestTestability:
commonTest with no Android dependencyWhat it is:
Third-party or JetBrains-supported libraries that provide a ViewModel class in commonMain that compiles to AndroidX ViewModel on Android and to a lifecycle-aware equivalent on other targets.
As of mid-2025, the main options are:
lifecycle-viewmodel KMP artifact (from AndroidX/JetBrains): JetBrains introduced official KMP support for androidx.lifecycle.ViewModel as a multiplatform artifact. This is the most official path when it matches the project's target set.When it is the right choice:
commonMain and have it behave correctly on all targets including AndroidWhat it provides:
commonMain with lifecycle-correct behavior per platformviewModelScope (or equivalent) provided per-platformWhat to verify:
SavedStateHandle availability: may not be supported on non-Android targets; check per-library docsSource-set placement:
commonMainTestability:
kotlinx-coroutines-test in commonTestAndroidX test infrastructure if the goal is shared test coverageWhat it is:
A stricter unidirectional architecture where user actions are explicitly typed as Intent or Action objects, the state holder reduces them into new State objects (often via a pure function or a reducer), and side effects are modeled explicitly as an Effect or SideEffect type.
MVI is a pattern, not a library. It can be implemented on top of any of the above state-holder mechanisms. Several community libraries (Orbit MVI, MVI Kotlin, etc.) provide MVI structure as a framework.
When it is the right choice:
X in current state S produces state S') is valuable for the use caseWhen it is likely overkill:
What it provides:
What to watch:
Source-set placement:
commonMainOne-time effects are the most common source of state-management bugs. The core mistake is modeling an effect as persistent state.
// WRONG: snackbar as persistent state
data class UiState(
val items: List<Item>,
val errorMessage: String? // stays in state after being shown — shows again on recomposition
)
A user sees the snackbar. They rotate the screen. The state is re-collected. The snackbar shows again. The error message is now part of permanent UI state and will survive any state restoration.
Option A: Separate SharedFlow for effects
// State holds only persistent UI truth
data class UiState(val items: List<Item>, val isLoading: Boolean)
// Effects are fire-and-forget
sealed interface UiEffect {
data class ShowError(val message: String) : UiEffect
data object NavigateToDetail : UiEffect
}
val uiState: StateFlow<UiState>
val effects: SharedFlow<UiEffect> // replay = 0
The SharedFlow with replay = 0 emits once to current subscribers. No replay on new subscription. UI collects this in a LaunchedEffect keyed to the composable lifecycle.
Option B: Nullable one-time event in state with explicit consumption
Some teams model effects as nullable fields with an explicit "consumed" action. This works but requires discipline — forgetting to send the consumed action is a common mistake.
Option C: Channel-backed flow (FIFO queue)
A Channel(BUFFERED) exposed as a receiveAsFlow() delivers effects one at a time to one subscriber. Good for effects that must not be dropped even during lifecycle transitions, but adds buffering complexity.
Review rule: Every non-null, non-boolean field in UiState that represents an action rather than a fact is likely a misplaced effect. Ask: "If this screen is recreated, should this still be shown?" If no, it is an effect.
UiState around screen truth, not data-layer truth// WRONG: mirrors the repository response
data class UiState(
val user: User?,
val posts: List<Post>?,
val isLoadingUser: Boolean,
val isLoadingPosts: Boolean,
val userError: Throwable?,
val postsError: Throwable?
)
// 64 incoherent combinations
// BETTER: models screen reality
sealed interface UiState {
data object Loading : UiState
data class Success(val user: User, val posts: List<Post>) : UiState
data class Error(val reason: ErrorReason, val canRetry: Boolean) : UiState
data class PartialContent(val user: User, val postsError: String) : UiState
}
data class with val fields; expose as interface or sealed type when substate variation existsThrowable exposed to UI — map to a presentation error type at the state-holder boundaryUiState| Platform | Config change behavior | State restoration | Scope owner |
|---|---|---|---|
| Android (ViewModel) | Survives by default | SavedStateHandle |
viewModelScope |
| Android (shared presenter in ViewModel) | Survives if hosted in ViewModel | Manual or SavedStateHandle via wrapper |
ViewModel-provided scope |
| iOS (SwiftUI ObservableObject) | N/A — no config changes | Manual (@AppStorage, custom) |
View owner; must cancel on deinit |
| iOS (UIKit VC) | N/A | Manual | VC; must cancel in viewDidDisappear/deinit |
| Desktop (Compose Desktop) | Window resize does not recreate | Manual | Root composable or explicit scope |
| Web (Compose Web/Wasm) | N/A | Manual or URL-driven | Entry point |
Check whether the state-holder satisfies the full contract: one observable state output, separate effects, user actions as inputs, no business rules in rendering, lifecycle correctness.
Flag as a concern when:
Check whether the chosen pattern matches the project's actual target set and team context.
Flag as a concern when:
commonMain without a KMP ViewModel abstraction layerCheck whether one-time effects are modeled distinctly from persistent UiState.
Flag as a concern when:
UiStateCheck whether UiState correctly models the states the screen can actually be in.
Flag as a concern when:
Throwable or error strings are exposed directlyUiStateCheck whether state-holder code is placed correctly for its actual dependencies.
Flag as a concern when:
commonMain without a KMP abstractioncommonMain state holder uses android.os.Bundle or other Android-specific typesCheck whether the coroutine scope the state holder uses is managed correctly for each platform.
Flag as a concern when:
Check whether state-holder logic is testable at the unit level.
Flag as a concern when:
commonMain without KMP abstraction (build fails on non-Android targets)UiStateUiStateWhen reviewing or designing state management, respond with:
State management summary
UiState shapeWhat is structurally sound
Issues by dimension
UiState shapeSeverity for each issue
Concrete recommendations
UiState restructuringSuggested target structure
Open risks
UiStateMutableStateFlow exposed as public API from a state holdercommonMain without a KMP abstractionThrowable or DTO types in UiState