
Minimalistic Model-View-Intent (MVI) library enhances application structure with easy navigation, testing, and debugging. Features include intent scheduling, state management, and dependency injection, simplifying app development and maintenance.
Minimalistic MVI implementation for Kotlin Multiplatform.
Key highlights:
RunIfNotRunning and CancelCurrentThenRun ensure safety when executing multiple intents of the same type. If the file gets too big, the components can be easily split across multiple files.state and the sendIntent function in @Composables. No need for more callbacks or State variables. Also, since it's always just one state that contains default values, it's trivial to create previews for different cases.[!NOTE]
The library implementation is stable and safe to use in production.
API is still unstable and might change with newer versions, but there is no need to update.
[!TIP]
At the moment, only JVM and Android artifacts are provided.
Support for the other platforms will be added soon.
In build.gradle.kts add:
dependencies {
implementation("ro.horatiu-udrea:mvi:0.1.2")
}In libs.versions.toml add:
[versions]
mvi = "0.1.2"
[libraries]
mvi = { module = "ro.horatiu-udrea:mvi", version.ref = "mvi" }and in build.gradle.kts add:
dependencies {
implementation(libs.mvi)
}Full implementation is in android-demo sources and tests for the components are in android-demo tests.
You can also find a template for Android Studio here.
Here is a description of the main components. They can either be in one single file or split into multiple files in the same package, for readability.
These exist to keep everything easy to read. They also help with navigating easily among components.
// Use "Go to definition" to easily navigate sections
typealias S = ProductsState
typealias I = ProductsIntent
typealias D = ProductsDependenciesThe viewmodel itself doesn't define any logic, just the initial state. It has callbacks that can be used for debugging or observability.
class ProductsViewModel(dependencies: D) : MVIViewModel<S, I, D>(initialState = ProductsState(), dependencies) {
// Put a breakpoint on the line with "Unit" and observe all received intents
// You can also send intents here, for example when you want to start associated processes
override fun onIntent(
intent: I,
sendIntent: (I) -> Unit
) = Unit
// Put a breakpoint on the line with "Unit" and observe all state changes
// Can also send intents for changes that can occur from multiple sources
override fun onStateChange(
description: String,
sourceIntent: I,
oldState: S,
newState: S,
sendIntent: (I) -> Unit
) = Unit
// Override this for debugging or reporting critical errors to users via an intent
override fun onException(
intent: I,
exception: Throwable,
sendIntent: (I) -> Unit
) {
super.onException(intent, exception, sendIntent)
}
}The state is a simple data class, preferably immutable.
data class ProductsState(
val products: List<Product> = emptyList(),
val productsLoading: Boolean = false,
val purchaseError: Boolean = false
)
data class Product(val id: Int, val name: String, val price: Double)Here are the intent definitions as immutable data objects and data classes. They also contain the logic to handle the intent.
The state object is used to change the state or schedule other intents, while Run,
RunIfNotRunning, RunAfterCurrent, CancelCurrentThenRun and CancelIntent are used to control the scheduling of intent handlers.
sealed interface ProductsIntent : IntentHandler<S, I, D> {
data object RefreshProducts : I, RunIfNotRunning<S, I, D>({ state ->
// Change the state and provide a description
state.change("Products are loading") { oldState -> oldState.copy(productsLoading = true) }
// Access dependencies from scope
val products = getProductsUseCase()
// Change the state and provide a description
state.change("Products updated") { oldState ->
oldState.copy(products = products, productsLoading = false)
}
})
data class BuyProduct(val product: Product) : I, Run<S, I, D>(run@{ state ->
// Access the current state and use it.
// Do this only when you need up-to-date info,
// otherwise include values in the intent data class and send them from UI
val productNumber = state.read("Read current number of products") { it.products.size }
trackProductNumberUseCase(productNumber)
val successful = buyProductUseCase(product)
if (!successful) {
// Change state to loading products, no description provided when it's trivial
state.change("Product purchase not successful") { it.copy(purchaseError = true) }
return@run
}
// Express the reason for which the state was not changed
state.keep("Bought product, refreshing products now")
// Use other intents if needed. Does not suspend, only schedules intent handling.
state.schedule(RefreshProducts)
})
data object DismissPurchaseError : I, Run<S, I, D>({ state ->
// Change state with no description provided when it's trivial
state.change { it.copy(purchaseError = false) }
})
}The dependencies are grouped in a separate class and can be injected as normal.
// Group all dependencies here. This can be injected using your favorite DI tool.
class ProductsDependencies(
val getProductsUseCase: suspend () -> List<Product>,
val buyProductUseCase: suspend (Product) -> Boolean,
val trackProductNumberUseCase: suspend (Int) -> Unit
)Minimalistic MVI implementation for Kotlin Multiplatform.
Key highlights:
RunIfNotRunning and CancelCurrentThenRun ensure safety when executing multiple intents of the same type. If the file gets too big, the components can be easily split across multiple files.state and the sendIntent function in @Composables. No need for more callbacks or State variables. Also, since it's always just one state that contains default values, it's trivial to create previews for different cases.[!NOTE]
The library implementation is stable and safe to use in production.
API is still unstable and might change with newer versions, but there is no need to update.
[!TIP]
At the moment, only JVM and Android artifacts are provided.
Support for the other platforms will be added soon.
In build.gradle.kts add:
dependencies {
implementation("ro.horatiu-udrea:mvi:0.1.2")
}In libs.versions.toml add:
[versions]
mvi = "0.1.2"
[libraries]
mvi = { module = "ro.horatiu-udrea:mvi", version.ref = "mvi" }and in build.gradle.kts add:
dependencies {
implementation(libs.mvi)
}Full implementation is in android-demo sources and tests for the components are in android-demo tests.
You can also find a template for Android Studio here.
Here is a description of the main components. They can either be in one single file or split into multiple files in the same package, for readability.
These exist to keep everything easy to read. They also help with navigating easily among components.
// Use "Go to definition" to easily navigate sections
typealias S = ProductsState
typealias I = ProductsIntent
typealias D = ProductsDependenciesThe viewmodel itself doesn't define any logic, just the initial state. It has callbacks that can be used for debugging or observability.
class ProductsViewModel(dependencies: D) : MVIViewModel<S, I, D>(initialState = ProductsState(), dependencies) {
// Put a breakpoint on the line with "Unit" and observe all received intents
// You can also send intents here, for example when you want to start associated processes
override fun onIntent(
intent: I,
sendIntent: (I) -> Unit
) = Unit
// Put a breakpoint on the line with "Unit" and observe all state changes
// Can also send intents for changes that can occur from multiple sources
override fun onStateChange(
description: String,
sourceIntent: I,
oldState: S,
newState: S,
sendIntent: (I) -> Unit
) = Unit
// Override this for debugging or reporting critical errors to users via an intent
override fun onException(
intent: I,
exception: Throwable,
sendIntent: (I) -> Unit
) {
super.onException(intent, exception, sendIntent)
}
}The state is a simple data class, preferably immutable.
data class ProductsState(
val products: List<Product> = emptyList(),
val productsLoading: Boolean = false,
val purchaseError: Boolean = false
)
data class Product(val id: Int, val name: String, val price: Double)Here are the intent definitions as immutable data objects and data classes. They also contain the logic to handle the intent.
The state object is used to change the state or schedule other intents, while Run,
RunIfNotRunning, RunAfterCurrent, CancelCurrentThenRun and CancelIntent are used to control the scheduling of intent handlers.
sealed interface ProductsIntent : IntentHandler<S, I, D> {
data object RefreshProducts : I, RunIfNotRunning<S, I, D>({ state ->
// Change the state and provide a description
state.change("Products are loading") { oldState -> oldState.copy(productsLoading = true) }
// Access dependencies from scope
val products = getProductsUseCase()
// Change the state and provide a description
state.change("Products updated") { oldState ->
oldState.copy(products = products, productsLoading = false)
}
})
data class BuyProduct(val product: Product) : I, Run<S, I, D>(run@{ state ->
// Access the current state and use it.
// Do this only when you need up-to-date info,
// otherwise include values in the intent data class and send them from UI
val productNumber = state.read("Read current number of products") { it.products.size }
trackProductNumberUseCase(productNumber)
val successful = buyProductUseCase(product)
if (!successful) {
// Change state to loading products, no description provided when it's trivial
state.change("Product purchase not successful") { it.copy(purchaseError = true) }
return@run
}
// Express the reason for which the state was not changed
state.keep("Bought product, refreshing products now")
// Use other intents if needed. Does not suspend, only schedules intent handling.
state.schedule(RefreshProducts)
})
data object DismissPurchaseError : I, Run<S, I, D>({ state ->
// Change state with no description provided when it's trivial
state.change { it.copy(purchaseError = false) }
})
}The dependencies are grouped in a separate class and can be injected as normal.
// Group all dependencies here. This can be injected using your favorite DI tool.
class ProductsDependencies(
val getProductsUseCase: suspend () -> List<Product>,
val buyProductUseCase: suspend (Product) -> Boolean,
val trackProductNumberUseCase: suspend (Int) -> Unit
)