
Lightweight MVI-like library facilitates state management with reactive entities, a clear DSL, and customizable core directives. Implements state machines using flows, handling intents and states efficiently.
State Ex Machina is a MVI-like library written in Kotlin Multiplatform.
Why do we need another MVI library?
Because we simply couldn't find one that was easy to start working with, lightweight and that would
cover enough use cases.
Core concepts
Flow to handle and store state changesHere's a simple example to show off the fundaments on which the library is based on.
The user wants to add two numbers and see the result of the sum.
Lastly saving the total sum with a network call.
// settings.gradle
dependencyResolutionManagement {
repositories {
// ...
mavenCentral()
}
}
// build.gradle
// MVI foundation
implementation("io.github.gionni2d:state-ex-machina-foundation:<latest-version>")
// Jetpack Compose MVI extensions
implementation("io.github.gionni2d:state-ex-machina-ext-compose:<latest-version>")
// AndroidX ViewModel MVI extensions
implementation("io.github.gionni2d:state-ex-machina-ext-viewmodel:<latest-version>")Intents represent user intentions, for example the intention to type a number.
sealed interface SumIntent : Intent {
data class TypeFirstNumber(val firstNumber: String) : SumIntent
data class TypeSecondNumber(val secondNumber: String) : SumIntent
object Sum : SumIntent
object SaveSum : SumIntent
}State represents a photo of all the dynamic information needed to present the view and for the model to interact with the domain (and update itself)
data class SumState(
val firstNumber: Int = 0,
val secondNumber: Int = 0,
val sum: Int = 0
) : StateReducers are pure functions that takes in input the old state and return a new state. In these functions is where you want to define the state update logic.
ReducerFactory is an abstraction on reducers that we're adopting to try to divide as much as possible the state update logic from the Model, using High Order Functions.
interface SumReducersFactory {
fun updateFirstNumber(n: Int): Reducer<SumState>
fun updateSecondNumber(n: Int): Reducer<SumState>
val updateSum: Reducer<SumState>
}
class SumReducersFactoryImpl : SumReducersFactory {
override fun updateFirstNumber(
n: Int
) = Reducer<SumState> { s ->
s.copy(firstNumber = n)
}
override fun updateSecondNumber(
n: Int
) = Reducer<SumState> { s ->
s.copy(secondNumber = n)
}
override val updateSum = Reducer<SumState> { s ->
s.copy(sum = s.firstNumber + s.secondNumber)
}
}The model holds the representation of the state and updates it with the reducers, it's the layer responsible for most of the business logic.
Model and override the function subscribeTo,
this is the scope where you can update the state and call coroutineson to react to user intentsupdateState or use sideEffect to elaborate data from a repository and morelaunchedEffect always execute code when the function subscribeTo of the Model is called)Model is immutable, every function or variable declared inside its scope should only be called in subscribeTo
import state.ex.machina.foundation.Model
import state.ex.machina.foundation.store
import state.ex.machina.dsl.stateMachine
import state.ex.machina.dsl.updateState
class SumModel(
private val coroutineScope: CoroutineScope
) : Model<SumState, SumIntent> {
private val reducers: SumReducersFactory = SumReducersFactoryImpl()
private val repository: SumRepository = SumRepository()
private val _uiEffect: MutableSharedFlow<SumUIEffect> = MutableSharedFlow()
val uiEffect: Flow<SumUIEffect> = _uiEffect.toSharedFlow()
override fun subscribeTo(intents: Flow<SumIntent>) = stateMachine(
store = store(SumState()),
intents = intents,
coroutineScope = coroutineScope,
) {
on<SumIntent.TypeFirstNumber>() updateState { reducers.updateFirstNumber(it.firstNumber.toInt()) }
on<SumIntent.TypeSecondNumber>() updateState { reducers.updateSecondNumber(it.secondNumber.toInt()) }
on<SumIntent.Sum>() updateState reducers.updateSum
on<SumIntent.SaveSum>() sideEffect {
repository.saveSum(currentState.sum)
_uiEffect.emit(SumUIEffect.ShowSumSavedNotification)
}
}
}With the library extension for ViewModel we can utilize the stateMachine that calls
for viewModelScope as coroutine scope.
import state.ex.machina.foundation.store
import state.ex.machina.viewmodel.stateMachine
class SumModel : ViewModel(), Model<SumState, SumIntent> {
override fun subscribeTo(intents: Flow<SumIntent>) = stateMachine(
store = store(SumState()),
intents = intents
) {
//
}
}@Composable
fun SumScreen(model: Model<SumState, SumIntent>) {
val (state, onIntent) = rememberStateMachine(model)
SumScreen(
state = state,
onTypeFirstNumber = { SumIntent.TypeFirstNumber(it).let(onIntent) },
onTypeSecondNumber = { SumIntent.TypeSecondNumber(it).let(onIntent) },
onSum = { SumIntent.Sum.let(onIntent) },
onSaveSum = { SumIntent.SaveSum.let(onIntent) },
)
}
@Composable
private fun SumScreen(
state: SumState,
onTypeFirstNumber: (Int) -> Unit,
onTypeSecondNumber: (Int) -> Unit,
onSum: () -> Unit,
onSaveSum: () -> Unit,
) {
// render UI using data from 'state' and wire intents to UI components actions
}State Ex Machina is a MVI-like library written in Kotlin Multiplatform.
Why do we need another MVI library?
Because we simply couldn't find one that was easy to start working with, lightweight and that would
cover enough use cases.
Core concepts
Flow to handle and store state changesHere's a simple example to show off the fundaments on which the library is based on.
The user wants to add two numbers and see the result of the sum.
Lastly saving the total sum with a network call.
// settings.gradle
dependencyResolutionManagement {
repositories {
// ...
mavenCentral()
}
}
// build.gradle
// MVI foundation
implementation("io.github.gionni2d:state-ex-machina-foundation:<latest-version>")
// Jetpack Compose MVI extensions
implementation("io.github.gionni2d:state-ex-machina-ext-compose:<latest-version>")
// AndroidX ViewModel MVI extensions
implementation("io.github.gionni2d:state-ex-machina-ext-viewmodel:<latest-version>")Intents represent user intentions, for example the intention to type a number.
sealed interface SumIntent : Intent {
data class TypeFirstNumber(val firstNumber: String) : SumIntent
data class TypeSecondNumber(val secondNumber: String) : SumIntent
object Sum : SumIntent
object SaveSum : SumIntent
}State represents a photo of all the dynamic information needed to present the view and for the model to interact with the domain (and update itself)
data class SumState(
val firstNumber: Int = 0,
val secondNumber: Int = 0,
val sum: Int = 0
) : StateReducers are pure functions that takes in input the old state and return a new state. In these functions is where you want to define the state update logic.
ReducerFactory is an abstraction on reducers that we're adopting to try to divide as much as possible the state update logic from the Model, using High Order Functions.
interface SumReducersFactory {
fun updateFirstNumber(n: Int): Reducer<SumState>
fun updateSecondNumber(n: Int): Reducer<SumState>
val updateSum: Reducer<SumState>
}
class SumReducersFactoryImpl : SumReducersFactory {
override fun updateFirstNumber(
n: Int
) = Reducer<SumState> { s ->
s.copy(firstNumber = n)
}
override fun updateSecondNumber(
n: Int
) = Reducer<SumState> { s ->
s.copy(secondNumber = n)
}
override val updateSum = Reducer<SumState> { s ->
s.copy(sum = s.firstNumber + s.secondNumber)
}
}The model holds the representation of the state and updates it with the reducers, it's the layer responsible for most of the business logic.
Model and override the function subscribeTo,
this is the scope where you can update the state and call coroutineson to react to user intentsupdateState or use sideEffect to elaborate data from a repository and morelaunchedEffect always execute code when the function subscribeTo of the Model is called)Model is immutable, every function or variable declared inside its scope should only be called in subscribeTo
import state.ex.machina.foundation.Model
import state.ex.machina.foundation.store
import state.ex.machina.dsl.stateMachine
import state.ex.machina.dsl.updateState
class SumModel(
private val coroutineScope: CoroutineScope
) : Model<SumState, SumIntent> {
private val reducers: SumReducersFactory = SumReducersFactoryImpl()
private val repository: SumRepository = SumRepository()
private val _uiEffect: MutableSharedFlow<SumUIEffect> = MutableSharedFlow()
val uiEffect: Flow<SumUIEffect> = _uiEffect.toSharedFlow()
override fun subscribeTo(intents: Flow<SumIntent>) = stateMachine(
store = store(SumState()),
intents = intents,
coroutineScope = coroutineScope,
) {
on<SumIntent.TypeFirstNumber>() updateState { reducers.updateFirstNumber(it.firstNumber.toInt()) }
on<SumIntent.TypeSecondNumber>() updateState { reducers.updateSecondNumber(it.secondNumber.toInt()) }
on<SumIntent.Sum>() updateState reducers.updateSum
on<SumIntent.SaveSum>() sideEffect {
repository.saveSum(currentState.sum)
_uiEffect.emit(SumUIEffect.ShowSumSavedNotification)
}
}
}With the library extension for ViewModel we can utilize the stateMachine that calls
for viewModelScope as coroutine scope.
import state.ex.machina.foundation.store
import state.ex.machina.viewmodel.stateMachine
class SumModel : ViewModel(), Model<SumState, SumIntent> {
override fun subscribeTo(intents: Flow<SumIntent>) = stateMachine(
store = store(SumState()),
intents = intents
) {
//
}
}@Composable
fun SumScreen(model: Model<SumState, SumIntent>) {
val (state, onIntent) = rememberStateMachine(model)
SumScreen(
state = state,
onTypeFirstNumber = { SumIntent.TypeFirstNumber(it).let(onIntent) },
onTypeSecondNumber = { SumIntent.TypeSecondNumber(it).let(onIntent) },
onSum = { SumIntent.Sum.let(onIntent) },
onSaveSum = { SumIntent.SaveSum.let(onIntent) },
)
}
@Composable
private fun SumScreen(
state: SumState,
onTypeFirstNumber: (Int) -> Unit,
onTypeSecondNumber: (Int) -> Unit,
onSum: () -> Unit,
onSaveSum: () -> Unit,
) {
// render UI using data from 'state' and wire intents to UI components actions
}