SimpleMVI is a small Kotlin Multiplatform library for building explicit MVI-style state containers.
It provides a minimal set of contracts for state, intent, and effect, plus Compose Multiplatform helpers for lifecycle-aware effect collection and ViewModel integration.
This is intentionally a simple implementation. It does not force a classic MVI reducer, action pipeline, middleware, or store framework. You handle intents in onIntent, update state with updateState, and emit one-time effects when needed.
- Immutable UI state through
StateFlow. - One public input for UI actions:
onIntent(intent). - One-time UI events through a separate effects flow.
- No required reducer or middleware layer.
- Works without Compose, but includes Compose Multiplatform helpers.
- Small enough to use for screen ViewModels and shared app-level stores.
- Latest version: see the Maven Central badge above.
- Group ID:
io.github.v1rus-dev - Core module:
simple-mvi-core - Compose Multiplatform module:
simple-mvi-compose - Android helpers module:
simple-mvi-android
Version catalog
[versions]
simplemvi = "<latest-version>"
[libraries]
# Core MVI contracts and state container
simplemvi-core = { module = "io.github.v1rus-dev:simple-mvi-core", version.ref = "simplemvi" }
# Compose Multiplatform ViewModel and effect collection helpers
simplemvi-compose = { module = "io.github.v1rus-dev:simple-mvi-compose", version.ref = "simplemvi" }
# Android-only lifecycle and SavedStateHandle helpers
simplemvi-android = { module = "io.github.v1rus-dev:simple-mvi-android", version.ref = "simplemvi" }Gradle DSL
repositories {
google()
mavenCentral()
}
dependencies {
implementation("io.github.v1rus-dev:simple-mvi-core:<latest-version>")
// Compose Multiplatform helpers
implementation("io.github.v1rus-dev:simple-mvi-compose:<latest-version>")
// Android-only helpers, including SavedStateHandle support
implementation("io.github.v1rus-dev:simple-mvi-android:<latest-version>")
}| Module | Target | Use it for |
|---|---|---|
simple-mvi-core |
Android, iOS | MVI contracts, state holder, and effect flow. |
simple-mvi-compose |
Android, iOS | Compose Multiplatform MviViewModel and effect collection. |
simple-mvi-android |
Android | Android lifecycle helpers and SavedStateHandle extension. |
Every SimpleMVI feature uses the same three contracts:
import io.github.v1rusdev.simplemvi.core.EffectUi
import io.github.v1rusdev.simplemvi.core.IntentUi
import io.github.v1rusdev.simplemvi.core.StateUi
data class ProfileState(
val name: String = "Jon Doe",
val isRefreshing: Boolean = false,
) : StateUi
sealed interface ProfileIntent : IntentUi {
data object RefreshClick : ProfileIntent
data object BackClick : ProfileIntent
}
sealed interface ProfileEffect : EffectUi {
data object NavigateBack : ProfileEffect
data class ShowMessage(val text: String) : ProfileEffect
}StateUiis durable UI state and should be renderable at any time.IntentUiis a user or UI action.EffectUiis a one-time event such as navigation, snackbar, toast, or dialog.
Use MviViewModel when a Compose screen owns its state through a ViewModel.
import io.github.v1rusdev.simplemvi.compose.MviViewModel
class ProfileViewModel : MviViewModel<ProfileState, ProfileIntent, ProfileEffect>(
initialState = ProfileState(),
) {
override fun onIntent(intent: ProfileIntent) {
when (intent) {
ProfileIntent.RefreshClick -> refresh()
ProfileIntent.BackClick -> sendEffect(ProfileEffect.NavigateBack)
}
}
private fun refresh() {
updateState { copy(isRefreshing = true) }
sendEffect(ProfileEffect.ShowMessage("Refresh started"))
}
}Collect state and effects separately in Compose:
import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.github.v1rusdev.simplemvi.compose.CollectEffectsUiEvent
@Composable
fun ProfileRoute(
viewModel: ProfileViewModel,
onBack: () -> Unit,
) {
val state = viewModel.uiState.collectAsStateWithLifecycle()
CollectEffectsUiEvent(viewModel.uiEffects) { effect ->
when (effect) {
ProfileEffect.NavigateBack -> onBack()
is ProfileEffect.ShowMessage -> {
// Show a snackbar, toast, or dialog.
}
}
}
ProfileScreen(
state = state.value,
onIntent = viewModel::onIntent,
)
}The UI should call one function only:
ProfileScreen(
state = state.value,
onIntent = viewModel::onIntent,
)If you already have a base class, delegate SimpleMVI to a store created by mvi(...).
import androidx.lifecycle.ViewModel
import io.github.v1rusdev.simplemvi.core.SimpleMVI
import io.github.v1rusdev.simplemvi.core.mvi
class ProfileViewModel : ViewModel(),
SimpleMVI<ProfileState, ProfileIntent, ProfileEffect> by mvi(
initialState = ProfileState(),
) {
override fun onIntent(intent: ProfileIntent) {
when (intent) {
ProfileIntent.RefreshClick -> updateState {
copy(isRefreshing = true)
}
ProfileIntent.BackClick -> tryEmitEffect(ProfileEffect.NavigateBack)
}
}
}mvi(...) creates the backing state and effect flows. In this pattern it is called when the object that delegates to it is created.
You can use simple-mvi-core without ViewModel or Compose. This is useful for shared stores such as app theme, session state, filters, or any state that is not owned by a single screen.
import io.github.v1rusdev.simplemvi.core.EffectUi
import io.github.v1rusdev.simplemvi.core.IntentUi
import io.github.v1rusdev.simplemvi.core.SimpleMVI
import io.github.v1rusdev.simplemvi.core.StateUi
import io.github.v1rusdev.simplemvi.core.mvi
data class ThemeState(
val isDarkTheme: Boolean = false,
) : StateUi
sealed interface ThemeIntent : IntentUi {
data object ToggleTheme : ThemeIntent
}
sealed interface ThemeEffect : EffectUi
class ThemeStore : SimpleMVI<ThemeState, ThemeIntent, ThemeEffect> by mvi(
initialState = ThemeState(),
) {
override fun onIntent(intent: ThemeIntent) {
when (intent) {
ThemeIntent.ToggleTheme -> updateState {
copy(isDarkTheme = !isDarkTheme)
}
}
}
}The raw mvi(...) function creates a store, but its default onIntent does nothing. Wrap it in a class when you want intent handling:
val store = mvi<ThemeState, ThemeIntent, ThemeEffect>(
initialState = ThemeState(),
)
store.updateState {
copy(isDarkTheme = true)
}Because SimpleMVI is just an interface, you can create stores in DI and share them across screens.
import io.github.v1rusdev.simplemvi.core.SimpleMVI
import org.koin.core.qualifier.named
import org.koin.dsl.module
private const val ThemeStoreQualifier = "themeStore"
val appModule = module {
single<SimpleMVI<ThemeState, ThemeIntent, ThemeEffect>>(named(ThemeStoreQualifier)) {
ThemeStore()
}
}Then inject the same store from an app-level ViewModel and from a screen:
class MainViewModel(
themeStore: SimpleMVI<ThemeState, ThemeIntent, ThemeEffect>,
) : ViewModel() {
val themeState = themeStore.uiState
}@Composable
fun ThemeRoute(
themeStore: SimpleMVI<ThemeState, ThemeIntent, ThemeEffect>,
) {
val state = themeStore.uiState.collectAsStateWithLifecycle()
ThemeScreen(
isDarkTheme = state.value.isDarkTheme,
onToggleTheme = {
themeStore.onIntent(ThemeIntent.ToggleTheme)
},
)
}The Android module includes a small SavedStateHandle.getOrPut helper for route arguments and small saved values.
SavedStateHandle is optional. Use it only when the ViewModel needs to keep small Android state after recreation.
import androidx.lifecycle.SavedStateHandle
import io.github.v1rusdev.simplemvi.compose.android.getOrPut
val profileId = savedStateHandle.getOrPut("profile_id") {
"me"
}The repository includes a Compose Multiplatform sample app:
samples/compose-multiplatform-app
It demonstrates:
- A regular
androidx.lifecycle.ViewModelscreen withStateFlow. - A
SimpleMVIMviViewModelscreen where Compose sends onlyonIntent. - A named Koin singleton
SimpleMVIstore that controls the app theme. - Jetpack Navigation Compose in shared Compose code.
- Android and iOS entry points.
It also includes a native Android Jetpack Compose sample app:
samples/native-android-app
It demonstrates:
- A regular Android application module using
com.android.applicationandorg.jetbrains.kotlin.android. - AndroidX Jetpack Compose dependencies through the Compose BOM.
simple-mvi-compose-androidintegration withMviViewModel, lifecycle-aware effect collection, andSavedStateHandle.getOrPut.
- Keep state immutable and easy to inspect.
- Route UI events through one
onIntententry point. - Keep one-time effects separate from state.
- Support both screen-owned ViewModels and shared standalone stores.
- Stay lightweight enough for shared Kotlin code.
- Add Compose helpers without forcing UI dependencies into the core module.
SimpleMVI is licensed under the Apache License 2.0.
