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 { ... }
}
)
}
}