Exploring the DSL¶
FlowRedux provides a convenient DSL to describe your state machine. This page introduces and explains step by step the FlowRedux DSL.
Let’s build an example app using FlowRedux: This app loads a list of items from a web service. As we introduce more concepts of the DSL we will extend this example with more features.
This page is meant to be read from top to bottom.
FlowReduxStateMachine¶
The base class in FlowRedux is FlowReduxStateMachine
.
It has a very simple public API:
class FlowReduxStateMachine<State, Action> {
val state : Flow<State>
suspend fun dispatch(action : Action)
}
Every FlowReduxStateMachine
works on a State
class.
How you model your state is up to you and depends on what your app and business logic actually has as requirements.
You can collect the FlowReduxStateMachine.state : Flow<State>
(from Kotlin coroutines library) by calling .collect()
on it.
Whenever the state of the state machine changes, observers get the updated state via this Flow
.
We also need a way to “input” something to our state machine like a user has clicked on a button in the UI.
“Inputs” are called Actions
in FlowRedux.
An example is data class LoginSubmittedAction(val username : String, val password : String)
.
Again, how you model your Actions is up to you.
There are no constraints or limitations from FlowRedux.
You can dispatch an Action
with FlowReduxStateMachine.dispatch(action)
.
That should be enough information to get started with our example app based on FlowRedux.
Let’s define the States
for our state machine.
As said before we load a list of items from a web server (via http) and display that list.
While loading the list we show a loading indicator on the screen and if an error occurs we show an error message on the screen with a retry button.
This gives us the following states:
sealed interface ListState {
// Shows a loading indicator on screen
object Loading : ListState
// List of items loaded successfully, show it on screen
data class ShowContent(val items: List<Item>) : ListState
// Error while loading happened
data class Error(val cause: Throwable) : ListState
}
If the state machine reaches the Error
state then we display an error message in our UI but also a button the users of our app can click to retry loading the items.
This gives us the following Actions
:
sealed interface Action {
object RetryLoadingAction : Action
}
This is how the UI looks like:
Initial State¶
Every FlowReduxStateMachine
needs an initial state.
This specifies in which state the state machine starts.
In our example we start with the Loading
state.
class ItemListStateMachine(
private val httpClient: HttpClient
) : FlowReduxStateMachine<ListState, Action>(initialState = Loading) {
init {
spec {
// will be filled in next section
...
}
}
}
In FlowRedux we need an init {...}
block containing a spec { ... }
block inside.
The spec { ... }
block is actually where we write our state machine specification with the DSL.
inState<State>
¶
The first concept of the DSL we learn is inState
:
class ItemListStateMachine(
private val httpClient: HttpClient
) : FlowReduxStateMachine<ListState, Action>(initialState = Loading) {
init {
spec {
inState<Loading> {
...
}
}
}
}
inState<Loading>
is just an “entry point”.
Next let’s discuss what an inState
block can contain as triggers to actually “do something”:
onEnter
: Triggers whenever we enter that stateon<Action>
: Triggers whenever we are in this state and the specified action is triggered from the outside by callingFlowReduxStateMachine.dispatch(action)
.collectWhileInState( flow )
: You can subscribe to any arbitraryFlow
while your state machine is in that state.
Additionally onEnterStartStateMachine()
and onActionStartStateMachine()
can be placed inside an inState{ ... }
block, but we will talk about these advanced concepts that are useful for composing business logic later.
Let’s take a more closer look at 3 basic elements onEnter
, on<Action>
and collectWhileInState
as we build our state machine.
onEnter¶
What do we want to do when we enter the Loading
? We want to make the http request to load the items from our server, right?
Let’s specify that with the DSL in our state machine:
class ItemListStateMachine(
private val httpClient: HttpClient
) : FlowReduxStateMachine<ListState, Action>(initialState = Loading) {
init {
spec {
inState<Loading> {
onEnter { state: State<Loading> ->
// we entered the Loading state,
// so let's do the http request
try {
val items = httpClient.loadItems() // loadItems() is a suspend function
state.override { ShowContent(items) } // return ShowContent from onEnter block
} catch (t: Throwable) {
state.override { Error(t) } // return Error state from onEnter block
}
}
}
}
}
}
There are a some new things like onEnter
and State<T>
.
We will covered State<T>
in the next section.
Let’s talk about onEnter
:
onEnter { ... }
is running asynchronously in a coroutine. That means whatever you do inside theonEnter
block is not blocking anything else. You can totally run here long-running and expensive calls (like doing a http request).onEnter { ... }
expects a lambda (or function) with the following signature:onEnter( (State<T>) -> ChangedState<T> )
. We will cover that in detail in the next section.onEnter { ... }
is executed exactly once when the surroundinginState<T>
condition is met. It will only executed the next time when the state machine transitions out of the current state and back to it again.- The execution of the
onEnter { ... }
is canceled as soon as state condition specified in the surroundinginState
doesn’t hold anymore i.e. state has been changes by some other block of the DSL else. Recall that FlowRedux is a multi-threaded asynchronous state machine. We will talk about that later.
The key takeaway here is that with onEnter { ... }
you can do some work whenever your state machine is entering this state and then move on to another state by calling State.override()
or State.mutate()
To be able to fully understand the code snipped from above, let’s take a look at State<T>
and ChangedState<T>
.
State<T>
and ChangedState<T>
¶
FlowRedux has the concept of a State<T>
(please note that T
here is just a placeholder for generics).
It is used as a parameter for many DSL blocks like onEnter { state : State<MyState> }
etc.
With this State<T>
object you can get access to the actual state of your statemachine with State.snapshot
.
Additionally State<T>
is providing functions to mutate the state or completely override it.
Here is a summary of the API of State<T>
(simplified version, we will dive deeper in a bit):
class State<T> {
// This holds the state value of your state machine
val snapshot : T
// completely replaces the current state with a new one
fun override(newState : T) : ChangedState<T>
// mutates the current state value.
// This is useful if you want to change just a few properties of your state
// but not the whole state as .override() does.
fun mutate(block: T.() -> T ) : ChangedState<T>
// Special use case for the rare case where you really
// don't want to change the state.
fun noChange() : ChangedState<T>
}
Please note that override()
and mutate()
are just syntactic sugar of the same thing.
The reason why both exist is to clearly hint to other software engineers (i.e. pull request reviews) that you either want to move to an entirely new state or just change a few properties of the current state but overall want to stay in the same type of state.
- use
override()
to explicitly want to transition to an entirely new state - use
mutate()
if you want to change just some properties of the current state but stay in the same state class.
Examples:
spec {
// DO USE .override() to clearly say you want to move to another type of state
inState<Loading>{
onEnter{ state : State<Loading> ->
state.override { Error() } // OK: move from Loading to Error state
}
}
// DO NOT USE .mutate()
inState<Loading>{
onEnter{ state : State<Loading> ->
state.mutate { Error() } // compiler error!
}
}
}
data class ScreenStatisticsState(
val name : String,
val visitCounter : Int
)
spec {
// DO USE .mutate() to clearly indicate that you just want to
// change a property but overall stay in same type of state
inState<ScreenStatisticsState> {
onEnter { state : State<ScreenStatisticsState> ->
state.mutate { this.copy(visitCounter= this.visitCounter + 1) } // OK: just update a property but stay in ScreenStatisticsState
}
}
// DO NOT USE .override() as you don't want to move to another type of state
inState<ScreenStatisticsState> {
onEnter { state : State<ScreenStatisticsState> ->
state.override {
this.copy(visitCounter= this.visitCounter + 1) // compiles but hard to read
}
}
}
}
As you see from a State<T>
you can produce a ChangedState
.
ChangedState
is something that simply tells FlowRedux internally how the reducer of the FlowReduxStateMachine should merge and compute the next state of your statemachine.
ChangedState
is not meant to be used or instantiated by you manually.
You may wonder “what about writing unit tests?”.
We will cover testing and best practices in a dedicated section.
We will dive deeper on State.override()
and State.mutate()
as we continue with our ItemListStateMachine
example.
on<Action>
¶
How do we deal with external user input like clicks in FlowRedux?
This is what Action
is for.
With the DSL of FlowRedux you can specify what should be done when a certain Action
(triggered by the user) happened.
In our example we want to retry loading if we are in Error
state. In the Error
state our UI shows a error text and a button the user can click on to retry loading the list of items.
Clicking on that button dispatches a RetryLoadingAction
to our state machine.
Let’s extend our ItemListStateMachine
to react on such an action:
class ItemListStateMachine(
private val httpClient: HttpClient
) : FlowReduxStateMachine<ListState, Action>(initialState = Loading) {
init {
spec {
inState<Loading> {
onEnter { state: State<Loading> ->
// We have discussed this block already in a previous section
try {
val items = httpClient.loadItems()
state.override { ShowContent(items) }
} catch (t: Throwable) {
state.override { Error(t) }
}
}
}
// let's add a new inState{...} with an on{...} block
inState<Error> {
on<RetryLoadingAction> { action: RetryLoadingAction, state: State<Error> ->
// This block triggers if we are in Error state and
// RetryLoadingAction has been dispatched to this state machine.
// In that case we transition to Loading state which then starts the http
// request to load items again as the inState<Loading> + onEnter { ... } triggers
state.override { Loading }
}
}
}
}
}
A on { ... }
block gets 2 parameters: action
which is the actual instance of the Action
that triggered this block
and state : State<T>
which gives us access to the current state and let us to state transitions with .override()
.
on { ... }
is actually pretty similar to onEnter {...}
just with a different “trigger” (action vs. entering a state)
. Furthermore, on { ... }
has the same characteristics as onEnter { ... }
:
on { ... }
is running asynchronously in a coroutine. That means whatever you do inside theon
block is not blocking anything else. You can totally run here long-running and expensive calls (like doing a http request).on { ... }
expects a lambda (or function) with the following signature:(action : Action , state : State<T>) -> ChangedState<T>
.- The execution of the
on { ... }
is canceled as soon as state condition specified in the surroundinginState
block doesn’t hold anymore (i.e. state has been changes by something else).
So far this is how our result look like:
collectWhileInState()¶
This one is useful if you want to collect a Flow
(from Kotlin Coroutines) only while being exactly in that state.
To give a concrete example how this is useful let’s extend our ItemListStateMachine
example.
Let’s say whenever our state machine is in Error
state we want
to retry loading the items after 3 seconds in Error
state or anytime before the 3 seconds have elapsed if the user clicks the retry button.
Furthermore the 3 seconds countdown timer should be displayed in our UI as well.
This is how the UI should look like:
To implement this let’s first extend our Error
state:
data class Error(
val cause: Throwable,
val countdown: Int // This value is decreased from 3 then 2 then 1 and represents the countdown value.
) : ListState
Now let’s add some countdown capabilities to our state machine by using collectWhileInState()
:
class ItemListStateMachine(
private val httpClient: HttpClient
) : FlowReduxStateMachine<ListState, Action>(initialState = Loading) {
init {
spec {
inState<Loading> {
onEnter { state: State<Loading> ->
// We have discussed this block already in a previous section
try {
val items = httpClient.loadItems()
state.override { ShowContent(items) }
} catch (t: Throwable) {
state.override { Error(t, countdown = 3) } // countdown is new
}
}
}
inState<Error> {
on<RetryLoadingAction> { action: RetryLoadingAction, state: State<Error> ->
// We have discussed this block already in a previous section
state.override { Loading }
}
val timer : Flow<Int> = timerThatEmitsEverySecond()
collectWhileInState(timer) { timerValue: Int, state: State<Error> ->
// This block triggers every time the timer emits
// which happens every second
state.override { // we use .override() because we could move to another type of state
// inside this block, this references Error state
if (this.countdown > 0)
this.copy(countdown = this.countdown - 1) // decrease countdown by 1 second
else
Loading // transition to the Loading state
}
}
}
}
}
private fun timerThatEmitsEverySecond(): Flow<Int> = flow {
var timeElapsed = 0
while (isActive) { // is Flow still active?
delay(1_000) // wait 1 second
timeElapsed++
emit(timeElapsed) // Flow Emits value
}
}
}
Let’s look at the source code above step by step.
What is new is that Error
state contains now an additional
field countdown : Int
which we set on transitioning from Loading
to Error(countdown = 3)
(means 3 seconds left on the countdown clock).
We extend inState<Error> { ... }
block and add collectWhileInState(timer)
block.
timer
is a Flow<Int>
that emits a new (incremented) number every second.
collectWhileInState(timer)
internally calls .collect {...}
on the flow passed as first parameter (in our case the timer
).
The second parameter is the a block with the parameters timerValue : Int
and State<Error>
.
In other words: instead of calling timer.collect { ... }
directly you
call collectWhileInState(timer) { ... }
to collect the Flow.
FlowRedux then takes care of canceling the flow once the surrounding inState{ ... }
condition doesn’t hold anymore. In our case, the timer is automatically canceled once the state machine transitions from
Error
state into another state.
This happens either when the user clicks on the retry button and which
triggers on<RetryLoadingAction>
which causes a state transition to Loading
or when 3 seconds have elapsed (inside collectWhileInState(timer)
).
Effects¶
If you don’t want to change the state but do some work without changing the state i.e. logging, triggering google analytics or trigger navigation then Effects are what you are looking for.
The following counterparts to on<Action>
, onEnter
and collectWhileInState
exists:
onActionEffect<Action>
: Likeon<Action>
this triggers whenever the described Action is dispatched.onEnterEffect
: LikeonEnter
this triggers whenever you enter the state.collectWhileInStateEffect
: LikecollectWhileInState
this is used to collect aFlow
.
Effects behave the same way as their counterparts.
For example cancelation etc. works just the same way as described in the section of on<Action>
, onEnter
and collectWhileInState
.Effects
Usage:
class ItemListStateMachine : FlowReduxStateMachine<ListState, Action>(initialState = Loading) {
init {
spec {
inState<Error> {
onEnterEffect { stateSnapshot : Error ->
logMessage("Did enter $stateSnapshot") // note there is no state change
}
onActionEffect<RetryLoadingAction> { action : RetryLoadingAction, stateSnapshot : Error ->
analyticsTracker.track(ButtonClickedEvent()) // note there is no state change
}
val someFolow : Flow<String> = ...
collectWhileInStateEffect(someFlow) {value : String , stateSnapshot : Error ->
logMessage("Collected $value from flow while in state $stateSnapshot") // note there is no state change
}
}
}
}
}
Custom condition for inState¶
We already covered inState<State>
that builds upon the recommended best practice that every State of your state machine is expressed us it’s own type in Kotlin.
Sometimes, however, you need a bit more flexibility than just relaying on types to model state.
For that use case you can use inStateWithCondition(isInState: (State) -> Boolean)
.
Example: One could have also modeled the state for our ItemListStateMachine
as the following:
// TO MODEL YOUR STATE LIKE THIS IS NOT BEST PRACTICE!
// In a real world example we recommend using sealed class instead.
data class ListState(
val loading: Boolean, // true means loading, false means not loading
val items: List<Items>, // empty list if no items loaded yet
val error: Throwable?, // if not null we are in error state
val errorCountDown: Int? // the seconds for the error countdown
)
AGAIN, the example shown above is not the recommended way. We strongly recommend to use sealed classes instead to model state as shown at the beginning of this document.
We just do this for demo purpose to demonstrate a way how to customize inState
.
Given the state from above, what we can do now with our DSL is the following:
class ItemListStateMachine(
private val httpClient: HttpClient
) : FlowReduxStateMachine<ListState, Action>(
initialState = State(
loading = true,
items = emptyList(),
error = null,
errorCountDown = null
)
) {
init {
spec {
inStateWithCondition(isInState = { state -> state.loading == true }) {
onEnter { state: State<ListState> ->
// we entered the Loading, so let's do the http request
try {
val items = httpClient.loadItems()
state.mutate {
this.copy(loading = false, items = items, error = null, errorCountdown = null)
}
} catch (t: Throwable) {
state.mutate {
this.copy(loading = false, items = emptyList(), error = t, errorCountdown = 3)
}
}
}
}
inStateWithCondition(isInState = { state -> state.error != null }) {
on<RetryLoadingAction> { action : RetryLoadingAction, state : State<ListState> ->
state.mutate {
this.copy(loading = true, items = emptyList(), error = null, errorCountdown = null)
}
}
val timer : Flow<Int> = timerThatEmitsEverySecond()
collectWhileInState(timer) { value : Int, state : State<ListState> ->
state.mutate {
if (errorCountdown!! > 0)
// decrease the countdown by 1 second
this.copy(errorCountdown = this.errorCountdown!! - 1)
else
// transition to the Loading
this.copy(
loading = true,
items = emptyList(),
error = null,
errorCountdown = null
)
}
}
}
}
}
}
Instead of inState<State> { ... }
use inStateWithCondition
.
It takes a lambda as parameter that looks like (State) -> Boolean
so that.
If that lambda returns true
it means we are in that state, otherwise not (returning false).
The rest still remains the same.
You can use onEnter
, on<Action>
and collectWhileInState
the exact way as you already know.
However, since inStateWithCondition
has no generics, FlowRedux cannot infer types in onEnter
, on
, etc.
Acting across multiple states¶
If for whatever reason you want to trigger a state change for all states you can achieve that by
using inState<>
on a base class.
// DSL specs
spec {
inState<ListState> {
// on, onEnter, collectWhileInState for all states because
// ListState is the base class, thus these never get cancelled
}
inState<Loading> {
// on, onEnter, collectWhileInState specific to Loading state
}
inState<ShowContent> {
// on, onEnter, collectWhileInState specific to ShowContent state
}
...
}
In case you want to trigger state changes from a subset of states you could introduce another
level to your state class hierarchy. For example the following would allow you to have a
inState<PostLoading>
block to share actions between ShowContent
and Error
:
sealed interface ListState {
// Shows a loading indicator on screen
object Loading : ListState
sealed interface PostLoading : ListState
// List of items loaded successfully, show it on screen
data class ShowContent(val items: List<Item>) : PostLoading
// Error while loading happened
data class Error(val cause: Throwable) : PostLoading
}
ExecutionPolicy¶
Have you ever wondered what would happen if you would execute Action
very fast 1 after another?
For example:
spec {
inState<FooState> {
on<BarAction> { action, state : State<FooState> ->
delay(5000) // wait for 5 seconds
state.override { OtherState() }
}
}
}
The example above shows a problem with async. state machines like FlowRedux:
If our state machine is in FooState
and a BarAction
got triggered, we wait for 5 seconds and then set the state to another state.
What if while waiting 5 seconds (i.e. let’s say after 3 seconds of waiting) another BarAction
gets
triggered.
That is possible, right?
With ExecutionPolicy
you can specify what should happen in that case.
There are three options to choose from:
CANCEL_PREVIOUS
: This is the default one automatically applied unless specified otherwise. It would cancel any previous execution and just run the latest one. In the example mentioned it means the previous still runningBarAction
handler gets canceled and a new one with the lasterBarAction
starts.-
UNORDERED
: Choosing this causes all the blocks to continue running but there are no guarantees in which order. For example:spec { inState<FooState> { on<BarAction>(executionPolicy = FlapMapPolicy.UNORDERED) { _, state : State<FooState> -> delay(randomInt()) // wait for some random time state.override { OtherState } } } }
Let’s assume that we trigger two times
BarAction
. We use random amount of seconds for waiting. Since we useUNORDERED
as policyon<BarAction>
the handler block gets executed 2 times without canceling the previous one (that is the difference toCANCEL_PREVIOUS
). Moreover,UNORDERED
doesn’t make any promise on order of execution of the block (seeORDERED
if you need promises on order). Ifon<BarAction>
gets executed two times it will run in parallel and the the second execution could complete before the first execution (because using a random time of waiting). -
ORDERED
: In contrast toUNORDERED
andCANCEL_PREVIOUS
,ORDERED
will not runon<BarAction>
in parallel and will not cancel any previous execution. Instead,ORDERED
will preserve the order.
on<Action>
and collectWhileInstate()
can specify an ExecutionPolicy
:
on<Action>(executionPolicy = ExecutionPolicy.CANCEL_PREVIOUS) { ... }
collectWhileInState(executionPolicy = ExecutionPolicy.CANCEL_PREVIOUS) { ... }
Please note that onEnter
doesn’t have the option to specify ExecutionPolicy
.
Best practice: make your DSL spec readable¶
One very important aspect of the DSL is to provide a readable and maintainable way to reason about your state machine. Let’ take a look at our example state machine:
class ItemListStateMachine(
private val httpClient: HttpClient
) : FlowReduxStateMachine<ListState, Action>(initialState = Loading) {
init {
spec {
inState<Loading> {
onEnter { state: State<Loading> ->
// We have discussed this block already in a previous section
try {
val items = httpClient.loadItems()
state.override { ShowContent(items) }
} catch (t: Throwable) {
state.override { Error(t, countdown = 3) } // countdown is new
}
}
}
inState<Error> {
on<RetryLoadingAction> { action: RetryLoadingAction, state: State<Error> ->
// We have discussed this block already in a previous section
state.override { Loading }
}
val timer : Flow<Int> = timerThatEmitsEverySecond()
collectWhileInState(timer) { timerValue: Int, state: State<Error> ->
// This block triggers every time the timer emits
// which happens every second
state.override { // we use .override() because we could move to another type of state
// inside this block, this references Error state
if (this.countdown > 0)
this.copy(countdown = this.countdown - 1) // decrease countdown by 1 second
else
Loading // transition to the Loading state
}
}
}
}
}
private fun timerThatEmitsEverySecond(): Flow<Int> = flow {
var timeElapsed = 0
while (isActive) { // is Flow still active?
delay(1000) // wait 1 second
timeElapsed++
emit(timeElapsed) // Flow Emits value
}
}
}
Do you notice something? With more blocks we add the state machine itself gets harder to read, understand and maintain. What we are aiming for with FlowRedux and it’s DSL is to get a readable overview about what the state machine is supposed to do on a high level. If you take a look at the example from above, however, you will notice that it isn’t easy to read and get bloated with implementation details.
Extract logic to functions¶
We recommend keeping the DSL spec { ... }
block really short, expressive, readable and maintainable.
Therefore, instead of having implementation details in your DSL we recommend to extract that to functions instead.
Let’s refactor the example above to reflect this idea:
class ItemListStateMachine(
private val httpClient: HttpClient
) : FlowReduxStateMachine<ListState, Action>(initialState = Loading) {
// This is the specification of your state machine.
// Less implementation details, better readability.
init {
spec {
inState<Loading> {
onEnter { loadItemsAndMoveToContentOrError(it) }
}
inState<Error> {
on<RetryLoadingAction> { action, state ->
// For a single line statement it's ok to keep logic inside the block instead
// of extracting it a function (but it also depends on your testing strategy)
state.override { Loading }
}
collectWhileInState(timerThatEmitsEverySecond()) { value, state ->
decrementCountdownAndMoveToLoading(value, state)
}
}
}
}
//
// All the implementation details are in the functions below.
//
private suspend fun loadItemsAndMoveToContentOrError(state: State<Loading>): ChangedState<State> {
return try {
val items = httpClient.loadItems()
state.override { ShowContent(items) }
} catch (t: Throwable) {
state.override { Error(cause = t, countdown = 3) }
}
}
private fun decrementCountdownAndMoveToLoading(
value: Int,
state: State<Error>
): ChangedState<State> {
return state.override {
if (this.countdownTimeLeft > 0)
this.copy(countdown = countdownTimeLeft - 1)
else
Loading
}
}
private fun timerThatEmitsEverySecond(): Flow<Int> = flow {
var timeElapsed = 0
while (isActive) {
delay(1000)
timeElapsed++
emit(timeElapsed)
}
}
}
Moreover, have you notice that the extracted function now all get a similar method signature:
suspend fun doSomething(state : State<T>) : ChangedState<T>
We are now getting closer to pure functions.
This makes writing unit test easier because for the same input (State
) pure functions return the same output (ChangedState
).
We will talk about that more in detail in the dedicated section about testing best practices.
Composing state machines (hierarchical state machines)¶
With FlowRedux you can compose state machines from other state machines.
This concept is called hierarchical state machines.
In this section we will introduce onActionStartStateMachine()
and onEnterStartStateMachine()
.
Think about Jetpack Compose, SwiftUI or React.
They are declarative frameworks to build UI.
Furthermore, you are encourage by these frameworks to build reusable UI components.
Wouldn’t it be great to get the same for your business logic?
With FlowRedux’s onActionStartStateMachine()
and onEnterStartStateMachine()
you can do that.
Advantages: - reuse state machines while still keep them decoupled and encapsulated from each other - favor composition over inheritance - easier to test
onActionStartStateMachine()
¶
Let’s continue enhancing our ItemListStateMachine
.
ShowContent
state is defined as following:
data class ShowContent(val items: List<Item>) : ListState
data class Item(val id : Int, val name : String)
Let’s say we want to have the option to mark an Item
as favorite (and also remove an Item as favorite).
The favorite items are actually saved on a server and we communicate with it over http.
Let’s extend Item
class to model this new requirements:
data class Item(
val id : Int,
val name : String,
val favoriteStatus : FavoriteStatus
)
sealed interface FavoriteStatus {
val itemId : Int
// It is not marked as favorite yet
data class NotFavorite(override val itemId : Int) : FavoriteStatus
// Marked as favorites
data class Favorite(override val itemId : Int) : FavoriteStatus
// An operation (read: http request) is in progress to either mark
// it as favorite or not mark it as favorite
data class OperationInProgress(
override val itemId : Int,
val markAsFavorite : Boolean // true means mark as favorite, false means unmark it
) : FavoriteStatus
// The operation (read: http request) to either mark it as favorite
// or unmark it as favorite has failed; so did not succeed.
data class OperationFailed(
override val itemId : Int,
val markAsFavorite : Boolean // true means mark as favorite, false means unmark it
) : FavoriteStatus
}
You may wonder why we need FavoriteStatus
and why it is not just a Boolean
to reflect marked as favorite or not?
Remember: we also need to talk to a server (via http) whenever the user wants to mark an Item
as favorite or unmark it.
The UI looks like this:
Let’s for now ignore the ItemListStateMachine
and only focus on our new requirements: marking an Item
as favorite (or unmark it) plus the communication with our backend server to store that information.
We could add this new requirements with our DSL to ItemListStateMachine
somehow or we extract that into a small stand alone state machine.
Let’s call this state machine FavoriteStatusStateMachine
and use the FlowRedux DSL to define it’s logic.
Additionally, let’s say when an network error in the communication with the backend server happened we will show an error for 3 seconds and then reset back to either maked as favorite or not.
class FavoriteStatusStateMachine(
item : Item,
private val httpClient : HttpClient
) : FlowReduxStateMachine<FavoriteStatus, Nothing>( // doesn't handle Action, thus we can use Nothing
initialState = OperationInProgress(
itemId = item.itemId,
markAsFavorite = item.favoriteStatus is NotFavorite
)
) {
init {
spec {
inState<OperationInProgress>{
onEnter { state ->
toggleFavoriteAndSaveToServer(state)
}
}
inState<OperationFailed>{
onEnter{ state ->
waitFor3SecondsThenResetToOriginalState(state)
}
}
}
}
private suspend fun toggleFavoriteAndSaveToServer(
state : State<OperationInProgress>
) : ChangedState<FavoriteStatus>{
return try {
val itemId = state.snapshot.itemId
val markAsFavorite = state.snapshot.markAsFavorite
httpClient.toggleFavorite(
itemId = itemId,
markAsFavorite = markAsFavorite // if false then unmark it, if true mark it as favorite
)
if (markAsFavorite)
state.override { Favorite(itemId) }
else
state.override { NotFavorite(itemId) }
} catch(exception : Throwable){
state.override { OperationFailed(itemId, markAsFavorite) }
}
}
private suspend fun waitFor3SecondsThenResetToOriginalState(
state : State<OperationFailed>
) : ChangedState<FavoriteStatus> {
delay(3_000) // wait for 3 seconds
val itemId = state.snapshot.itemId
val markAsFavorite = state.snapshot.markAsFavorite
return if (markAsFavorite)
// marking as favorite failed,
// thus original status was "not marked as favorite"
state.override { NotFavorite(itemId) }
else
state.override { Favorite(itemId) }
}
}
All that FavoriteStatusStateMachine
does is making an http request to the backend and in case of error reset back to the previous state after showing an error state for 3 seconds.
This is how the UI should looks like:
Now let’s connect this with our ItemListStateMachine
by using onActionStartStateMachine()
.
data class ToggleFavoriteItemAction(val itemId : Int) : Action
class ItemListStateMachine(
private val httpClient: HttpClient
) : FlowReduxStateMachine<ListState, Action>(initialState = Loading) {
// This is the specification of your state machine.
// Less implementation details, better readability.
init {
spec {
inState<Loading> {
onEnter { loadItemsAndMoveToContentOrError(it) }
}
inState<Error> {
on<RetryLoadingAction> { action, state ->
state.override { Loading }
}
collectWhileInState(timerThatEmitsEverySecond()) { value, state ->
decrementCountdownAndMoveToLoading(value, state)
}
}
// NEW DSL block
inState<ShowContent> {
// on ToggleFavoriteItemAction start statemachine
onActionStartStateMachine(
stateMachineFactory = {
action: ToggleFavoriteItemAction, stateSnapshot : ShowContent ->
val item : Item = stateSnapshot.items.find { it == action.itemId}
// create and return a new FavoriteStatusStateMachine instance
FavoriteStatusStateMachine(item, httpClient)
},
stateMapper = {
itemListState : State<ShowContent>, favoriteStatus :FavoriteStatus ->
itemListState.mutate {
val itemToRepace : Item = this.items.find { it == favoriteStatus.itemId }
val updatedItem : Item = itemToReplace.copy(favoriteStatus = favoriteStatus)
// Create a copy of ShowContent state with the updated item
this.copy(items = this.items.copyAndReplace(itemToReplace, updatedItem) )
}
}
)
}
}
}
...
}
First, let’s take a look at onActionStartStateMachine()
public API.
It has 3 parameters.
Multiple overloads exists, and in our case the one with only 2 parameters is enough.
Nevertheless, let’s explain all 3 parameters of onActionStartStateMachine()
:
stateMachineFactory: (Action, State) -> FlowReduxStateMachine
: Inside this block you create a state machine. In our case we create aFavoriteStatusStateMachine
. You have access to the current state of theItemListStateMachine
and theAction
that has triggeredonActionStartStateMachine()
stateMapper: (State<T>, StateOfNewStateMachine) -> ChangedState<T>
: we need to have a way to combine the state of the newly started state machine with the one of the “current” state machine. In our case we need to combineItemListStateMachine
‘s state withFavoriteStatusStateMachine
‘s state. That is exactly whatstateMapper
is good for. The difference is thatItemListStateMachine
provides aState<T>
to thestateMapper
(first parameter) whereasFavoriteStatusStateMachine
provides the currentFavoriteState
(notState<FavoriteState>
). The reason is that at the end we need to get a compatible state forItemListStateMachine
and that is what we need to do through the already knownState<T>.override()
orState<T>.mutate()
methods.actionMapper: (Action) -> OtherStateMachineAction?
: We didn’t need this in our example above becauseFavoriteStatusStateMachine
is not dealing with any Action. In theory, however, we need to “forward” actions fromItemListStateMachine
toFavoriteStatusStateMachine
. But since the actions of the 2 state machines could be of different types, we would need to map an action type ofItemListStateMachine
to another action type ofFavoriteStatusStateMachine
ornull
if not all actions are supposed to be handled byFavoriteStatusStateMachine
. Returningnull
in theactionMapper
means the action is not forwarded toFavoriteStatusStateMachine
. Again, this is not needed here in this example, but in theory could be needed in other use cases.
You may wonder what the lifecycle of the state machine started from onActionStartStateMachine()
looks like:
- the state machine start (in our case FavoriteStatusStateMachine
) will be kept as long alive as the surrounding inState<State>
holds true. This works just like the other DSL primitives work (like on<Action>
). In our example a FavoriteStatusStateMachine
is canceled when ItemListStateMachine
transitions away from ShowContent
state.
- Every time an Action
that is handled by onActionStartStateMachine()
is dispatched, then the stateMachineFactory
is invoked and a new state machine gets started. Important is that actions are distinguished by it’s .equals()
method. In our example ToggleFavoriteItemAction(itemId = 1)
and ToggleFavoriteItemAction(itemId = 2)
are two different Action because ToggleFavoriteItemAction.equals()
also takes itemId
into account. Therefore, with 2 instances of FavoriteStatusStateMachine
are started, one for itemId = 1 and one for itemId = 2.
- if the .equals()
same ToggleFavoriteItemAction(itemId = 1)
gets dispatched, then the previous started state machine gets canceled and a new one starts (with the latest action
as trigger). There is always only 1 state machine for the same action
as trigger running.
Make DSL even more readable with custom DSL additions¶
In the previous section we have introduced onActionStartStateMachine()
but it is quite a bit of code in our otherwise nicely readable spec { }
block:
spec {
inState<Loading> {
onEnter { loadItemsAndMoveToContentOrError(it) }
}
inState<Error> {
on<RetryLoadingAction> { action, state ->
state.override { Loading }
}
collectWhileInState(timerThatEmitsEverySecond()) { value, state ->
decrementCountdownAndMoveToLoading(value, state)
}
}
inState<ShowContent> {
// Quite a bit of unreadable code
onActionStartStateMachine(
stateMachineFactory = {
action: ToggleFavoriteItemAction, stateSnapshot : ShowContent ->
val item : Item = stateSnapshot.items.find { it == action.itemId}
// create and return a new FavoriteStatusStateMachine instance
FavoriteStatusStateMachine(item, httpClient)
},
stateMapper = {
itemListState : State<ShowContent>, favoriteStatus :FavoriteStatus ->
itemListState.mutate {
val itemToReplace : Item = this.items.find { it == favoriteStatus.itemId }
val updatedItem : Item = itemToReplace.copy(favoriteStatus = favoriteStatus)
// Create a copy of ShowContent state with the updated item
this.copy(items = this.items.copyAndReplace(itemToReplace, updatedItem) )
}
}
)
}
}
We can do better than this, right?
How?
Which Kotlin extension functions and receivers.
The receiver type is InStateBuilderBlock
is what inState<S>
is operating in.
spec {
inState<Loading> {
onEnter { loadItemsAndMoveToContentOrError(it) }
}
inState<Error> {
on<RetryLoadingAction> { action, state ->
state.override { Loading }
}
collectWhileInState(timerThatEmitsEverySecond()) { value, state ->
decrementCountdownAndMoveToLoading(value, state)
}
}
inState<ShowContent> {
onToggleFavoriteActionStartStateToggleFavoriteStateMachine()
}
}
private fun InStateBuilderBlock.onToggleFavoriteActionStartStateToggleFavoriteStateMachine(){
onActionStartStateMachine(
stateMachineFactory = {
action: ToggleFavoriteItemAction, stateSnapshot : ShowContent ->
val item : Item = stateSnapshot.items.find { it == action.itemId}
// create and return a new FavoriteStatusStateMachine instance
FavoriteStatusStateMachine(item, httpClient)
},
stateMapper = {
itemListState : State<ShowContent>, favoriteStatus :FavoriteStatus ->
itemListState.mutate {
val itemToReplace : Item = this.items.find { it == favoriteStatus.itemId }
val updatedItem : Item = itemToReplace.copy(favoriteStatus = favoriteStatus)
// Create a copy of ShowContent state with the updated item
this.copy(items = this.items.copyAndReplace(itemToReplace, updatedItem) )
}
}
)
}
onEnterStartStateMachine()¶
Similar to onActionStartStateMachine()
FlowRedux provides a primitive to start a state machine onEnter{ ... }
The syntax looks quite similar to onActionStartStateMachine()
:
spec {
inState<MyState>{
onEnterStartStateMachine(
stateMachineFactory = { stateSnapshot : MyState -> SomeFlowReduxStateMachine() },
stateMapper = { state : State<MyState>, someOtherStateMachineState : S ->
state.override { ... }
}
)
}
}
Testing¶
You may wonder what is the best way to test a FlowReduxStateMachine
?
There are two strategies we want to discuss here in this section:
- functional integration tests: test the whole state machine as a whole.
- Unit tests to test only a certain handler such as
onEnter {}
,on<Action>
and so on.
Functional integration tests with Turbine¶
This is our recommended way for testing FlowReduxStateMachine
.
For this we need Turbine.
Turbine is a library that makes testing Flow
from Kotlin coroutine much easier.
Let’s say we want to test our ItemListStateMachine
.
With turbine we can do that step by ste quite easily:
import kotlinx.coroutines.test.runTest
@Test
fun `state machine starts with Loading state`() = runTest {
val statemachine = ItemListStateMachine(HttpClient())
statemachine.state.test {
// awaitItem() from Turbine waits until next state is emitted.
// FlowReduxStateMachine emits initial state immediately.
assertEquals(Loading, awaitItem())
}
}
@Test
fun `move from Loading to ShowContent state on successful http response`() = runTest {
val items : List<Item> = generatesomeFakeItems()
val httpClient = FakeHttpClient(successresponse = items)
val statemachine = ItemListStateMachine(httpClient)
statemachine.state.test {
assertEquals(Loading, awaitItem()) // initial state
assertEquals(ShowContent(items), awaitItem()) // loading successful --> ShowContent state
}
}
@Test
fun `move from Loading to Error state on error http response`() = runTest {
val exception = IOExpcetion("fake exception")
val httpClient = FakeHttpClient(error = exception)
val statemachine = ItemListStateMachine(httpClient)
statemachine.state.test {
assertEquals(Loading, awaitItem()) // initial state
assertEquals(Error(cause = exception, countdown = 3), awaitItem())
}
}
We can apply this pattern all the time, but isn’t it a bit annoying to always start our state machine from initial state and have to go thorough all the state transitions until we reach the state we want to test? Well, one nice side effect of using state machines is that you can jump to a certain state right from the beginning. To be able to do that we need to pass the initial state as constructor parameter like this:
class ItemListStateMachine(
private val httpClient: HttpClient,
initialState : ListState = Loading // now constructor parameter
) : <ListState, Action>(initialState) { ... }
Now let’s write a test that checks that pressing the retry button works:
@Test
fun `from Error state to Loading if RetryLoadingAction is dispatched`() = runTest {
val initialState = Error(cause = IOException("fake"), countdown = 3)
val statemachine = ItemListStateMachine(httpClient, initialState)
statemachine.state.test {
assertEquals(initialState, awaitItem())
// now we dispatch the retry action
statemachine.dispatch(RetryLoadingAction)
// next state should then be Loading
assertEquals(Loading, awaitItem())
}
}
@Test `once Error countdown is 0 move to Loading state`() = runTest {
val cause = IOException("fake")
val initialState = Error(cause = cause, countdown = 3)
val statemachine = ItemListStateMachine(httpClient, initialState)
statemachine.state.test {
assertEquals(initialState, awaitItem())
assertEquals(Error(cause, 2))
assertEquals(Error(cause, 1))
assertEquals(Error(cause, 0))
assertEquals(Loading, awaitItem())
}
}
Unit testing handlers¶
Another way how you can test your state machines is on unit test level, but it requires that you logic is extracted into functions.
For example, let’s say we want to unit test loadItemsAndMoveToContentOrError()
spec {
inState<Loading> {
onEnter { loadItemsAndMoveToContentOrError(it) }
}
}
suspend fun loadItemsAndMoveToContentOrError(state: State<Loading>): ChangedState<State> {
return try {
val items = httpClient.loadItems()
state.override { ShowContent(items) }
} catch (t: Throwable) {
state.override { Error(cause = t, countdown = 3) }
}
}
We can do that as such:
@Test
fun `on http success move to ShowContent state`() = runTest{
val items : List<Item> = generatesomeFakeItems()
val httpClient = FakeHttpClient(successresponse = items)
val statemachine = ItemListStateMachine(httpclient)
val startState = State(Loading) // Create a FlowRedux State object
val changedState : ChangedState<ListState> = statemachine.loadItemsAndMoveToContentOrError(startState)
val result : ListState = changedState.reduce(startState.snapshot) // FlowRedux API: you must call reduce
val expected = ShowContent(items)
assertEquals(expected, result)
}
With FlowRedux you can write unit tests, but there is a bit of overhead:
- You need to wrap the actual state into FlowRedux
State
class. - To get from a
ChangedState
to the actual value you need to call.reduce()
on it.
What we basically have to do here is what FlowRedux does internally. In the future we may provide a more convenient way to write this kind of unit tests with less overhead.