A lightweight, redux-like MVI implementation for the JVM using Kotlin coroutines.
The library internally uses StateFlow.
This library can be found on mavenCentral()
and depends on the kotlinx-coroutines-core
artifact.
repositories {
mavenCentral()
}
dependencies {
implementation("io.github.syex:skadi:$version")
}
For a deeper understand of the redux architecture please refer to other blog posts like this one. This sections serves as a quick overview of the most important classes you'll have to deal with using skadi.
Defines the current state your application currently is in, e.g. Loading
, when your app is currently
loading some content. The state can change as a result of applying a reducer. You have to implement
the reducer, which is a function which maps the current state and a change to a SkadiEffect.
A SkadiEffect
is the output of the reducer function. A state and an action performed in that state
trigger an effect. The effect contains a new state, an optional list of actions to perform and an
optional list of signals to send.
An action is something that leads to a new change. E.g. you may have an action to load the user profile and on success you want to perform a change that leads to a new state. Any actions you want to perform have to be defined. In skadi your actions are automatically called on a new coroutine.
Actions are part of a SkadiEffect
, so whenever you may want to move to a new state you can also
perform actions.
An action or an external event can trigger a change, which may lead to a new state. An example for an external event is, for instance, when the user clicks on a button.
The store is the base of any redux architecture, putting everything together and where you need to define your intitial state, reducer, actions etc.
For Android, there is a sample app showing how to use skadi in a ViewModel
.
In general, you need to create a SkadiStore
, which will be your single source of truth, managing a state
and handling any actions to change this state.
The SkadiStore
expects three types to be defined, first is the type of your state. A possible
implementation, where you have one state saying you're loading data and one where you're
displaying some data, could look like this:
sealed class ExampleState : SkadiState {
object Loading : ExampleState()
data class DisplayData(val data: List<String>) : ExampleState()
}
Second is the type of actions you want to perform. For instance, we could want to perform an action
that loads the data and additionally define a SkadiChange
for the case this action is successful.
sealed class Action {
object LoadData : Action() {
data class Success(val data: List<String>) : SkadiChange
}
}
Third are possible signals
that you want to send. These will be covered in another area. We can
simply put Kotlin's Nothing
here.
All put together, we can define our SkadiStore
like this:
val store = SkadiStore<ExampleState, Action, Nothing>(
initialState = Loading,
reducer = { state, change ->
when (state) {
Loading -> when (change) {
is LoadData.Success -> {
effect {
state { DisplayData(change.data) }
}
}
else -> unexpected(state, change)
}
}
},
actions = { action ->
when (action) {
LoadData -> {
val data = // in coroutine context, you can call suspend functions here
return LoadData.Success(movies)
}
}
},
coroutineScope = // scope in which the store should be active
)
You now can start collecting the state changes on store.stateFlow
.
This sections informs about more concepts skadi offers.
Sometimes you have to handle external events that trigger a change and therefore may lead to a new state. The easiest example is the click on a login button that obviously should login the user and move to a new screen.
A SkadiStore
has the perform(SkadiChange)
method exactly for that purpose. It will simply
pass the current state along with that change to your reducer where you can handle this change.
It's the easiest solution to just define your external events as another sealed class
:
sealed class ViewEvent : SkadiChange {
data class ItemClicked(val item: Item) : ViewEvent()
}
So you can easily call SkadiStore.perform(ItemClicked(item))
.
Actions are typically a side effect of a SkadiEffect
. Sometimes you may want to trigger an
action without calling your reducer to reduce boilerplate code.
For instance, initially you want to load some data, what is defined as an action. You'd have to
create a SkadiChange
for this purpose and handle this change in your reducer function, returning
a SkadiEffect
with the same state and your desired action as a side effect.
Instead, you can call SKadiStore.performAction(action)
, which will do the mentioned procedure for you.
You may not want to change the state
on every change that is passed to your reducer. Maybe, instead
you only want to show some message to the user or open another application or screen. That is what
signals are for. They are what you would call "fire and forget".
Signals are part of a SkadiEffect
, they can be collected via SkadiStore.signalFlow
.
There are some extension functions defined to reduce boilerplate code.
fun unexpected(state: SkadiState, change: SkadiChange)
Shortcut to throw an exception whenever you encounter a change in a state that you didn't expect. To be used in your reducer function.
fun SkadiState.same(actions: List<Action> = emptyList(), signals: List<Signal> = emptyList()): SkadiEffect
Shortcut to create a SkadiEffect
with the state
the function was called on, but passing some
actions
or signals
.
fun SkadiState.signal(signal: Signal): SkadiEffect
Shortcut to create a SkadiEffect
with the same state, only sending the passed signal
.
In general, I recommend using Turbine flow testing for
easier testing of skadi's stateFlow
and signalFlow
.