
Small MVI-style state container: immutable StateFlow state, single onIntent input, one-time effects, minimal contracts, store delegation, standalone stores, lightweight ViewModel/effect and saved-state helpers.
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.
StateFlow.onIntent(intent).io.github.v1rus-dev
simple-mvi-core
simple-mvi-compose
simple-mvi-android
[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" }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
}StateUi is durable UI state and should be renderable at any time.IntentUi is a user or UI action.EffectUi is 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:
androidx.lifecycle.ViewModel screen with StateFlow.SimpleMVI MviViewModel screen where Compose sends only onIntent.SimpleMVI store that controls the app theme.It also includes a native Android Jetpack Compose sample app:
samples/native-android-app
It demonstrates:
com.android.application and org.jetbrains.kotlin.android.simple-mvi-compose-android integration with MviViewModel, lifecycle-aware effect collection, and SavedStateHandle.getOrPut.onIntent entry point.SimpleMVI is licensed under the Apache License 2.0.
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.
StateFlow.onIntent(intent).io.github.v1rus-dev
simple-mvi-core
simple-mvi-compose
simple-mvi-android
[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" }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
}StateUi is durable UI state and should be renderable at any time.IntentUi is a user or UI action.EffectUi is 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:
androidx.lifecycle.ViewModel screen with StateFlow.SimpleMVI MviViewModel screen where Compose sends only onIntent.SimpleMVI store that controls the app theme.It also includes a native Android Jetpack Compose sample app:
samples/native-android-app
It demonstrates:
com.android.application and org.jetbrains.kotlin.android.simple-mvi-compose-android integration with MviViewModel, lifecycle-aware effect collection, and SavedStateHandle.getOrPut.onIntent entry point.SimpleMVI is licensed under the Apache License 2.0.