A lightweight Kotlin Multiplatform MVI (Model-View-Intent) library built on top of AndroidX ViewModel and Coroutines.
Add the dependency to your build.gradle.kts:
# Version catalog (gradle/libs.versions.toml)
[versions]
kmvi = "<version>"
[libraries]
kmvi = { module = "io.github.natobytes:kmvi", version.ref = "kmvi" }// build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.kmvi)
}
}
}Or directly:
implementation("io.github.natobytes:kmvi:<version>")KMVI supports JVM, Android, and iOS (x64, ARM64, Simulator ARM64 via XCFramework).
See the sample/ module for a complete Todo List example demonstrating collections, multiple intents, and effects (undo on remove).
KMVI implements a unidirectional data flow:
Intent -> Processor -> Flow<Result> -> ViewModel -> Action -> Reducer -> State
-> Effect -> Flow<Effect>
- Intents are processed sequentially, so each processor sees the latest state
- Actions update state through a pure reducer function
- Effects are one-shot events (navigation, toasts, etc.) delivered via a buffered channel
// What the user can do
sealed interface CounterIntent : Intent {
data object Increment : CounterIntent
data object Decrement : CounterIntent
data object Reset : CounterIntent
}
// Your screen state
data class CounterState(
val count: Int = 0,
) : State
// State mutations
sealed interface CounterAction : Action {
data class UpdateCount(val count: Int) : CounterAction
}
// One-shot side effects
sealed interface CounterEffect : Effect {
data object CounterReset : CounterEffect
}The Processor maps intents to a stream of results (actions and/or effects). It receives the current state so you can make decisions based on it.
class CounterProcessor : Processor<CounterIntent, CounterState> {
override fun process(
input: CounterIntent,
state: CounterState,
): Flow<Result> = when (input) {
is CounterIntent.Increment -> flowOf(CounterAction.UpdateCount(state.count + 1))
is CounterIntent.Decrement -> flowOf(CounterAction.UpdateCount(state.count - 1))
is CounterIntent.Reset -> flow {
emit(CounterAction.UpdateCount(0))
emit(CounterEffect.CounterReset)
}
}
}Both Processor and Reducer are fun interfaces, so you can use SAM conversion for simple cases:
val processor = Processor<CounterIntent, CounterState> { input, state ->
when (input) {
is CounterIntent.Increment -> flowOf(CounterAction.UpdateCount(state.count + 1))
is CounterIntent.Decrement -> flowOf(CounterAction.UpdateCount(state.count - 1))
is CounterIntent.Reset -> flow {
emit(CounterAction.UpdateCount(0))
emit(CounterEffect.CounterReset)
}
}
}The Reducer is a pure function that applies an action to the current state and returns a new state.
class CounterReducer : Reducer<CounterAction, CounterState> {
override fun reduce(
action: CounterAction,
state: CounterState,
): CounterState = when (action) {
is CounterAction.UpdateCount -> state.copy(count = action.count)
}
}Extend KMVIViewModel with your contract types and wire everything together.
class CounterViewModel : KMVIViewModel<CounterIntent, CounterAction, CounterEffect, CounterState>(
initialState = CounterState(),
processor = CounterProcessor(),
reducer = CounterReducer(),
)@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
val state by viewModel.state.collectAsState()
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is CounterEffect.CounterReset -> { /* show snackbar, etc. */ }
}
}
}
Column {
Text("Count: ${state.count}")
Button(onClick = { viewModel.process(CounterIntent.Increment) }) {
Text("+")
}
Button(onClick = { viewModel.process(CounterIntent.Decrement) }) {
Text("-")
}
Button(onClick = { viewModel.process(CounterIntent.Reset) }) {
Text("Reset")
}
}
}Override onError in your ViewModel to handle exceptions thrown by processors. By default, errors are logged and the ViewModel continues processing subsequent intents.
class CounterViewModel : KMVIViewModel<CounterIntent, CounterAction, CounterEffect, CounterState>(
initialState = CounterState(),
processor = CounterProcessor(),
reducer = CounterReducer(),
) {
override fun onError(throwable: Throwable) {
// Log, report to crash analytics, etc.
}
}| Type | Description |
|---|---|
Intent |
Marker interface for user actions / events |
State |
Marker interface for immutable UI state |
Result |
Sealed interface — parent of Action and Effect |
Action |
A state mutation, processed by the Reducer |
Effect |
A one-shot side effect (navigation, toasts, etc.) |
Processor<I, S> |
Transforms an Intent + current State into Flow<Result> |
Reducer<A, S> |
Pure function: (Action, State) -> State |
KMVIViewModel<I, A, E, S> |
Abstract ViewModel wiring everything together |
| Member | Type | Description |
|---|---|---|
state |
StateFlow<S> |
Current state, observable |
effects |
Flow<E> |
One-shot effects, each delivered to exactly one collector |
process(intent) |
fun |
Entry point — submit an intent for processing |
onError(throwable) |
protected open fun |
Override to handle processor errors |
Copyright 2026 natobytes
Licensed under the Apache License, Version 2.0