We are going to see how to create a State Machine and implement it on Jetpack Compose
Why do we need a state machine on Jetpack Compose? Let’s start from the basics!
Jetpack Compose
Jetpack Compose is the new framework released by Google that will replace the current XML view system introduced a decade ago. It’s very different from the legacy one because we are moving from the Imperative Programming approach to Declarative Programming, where the UI components are functions, and every argument represents a property or even a state of our component.
The state: this is the most important part of this article because basically Compose was designed to react to a change of state, this means that our primary goal is to find the best way to manipulate this state.
When I say “best” I mean:
- maintaining this state consistent during the execution
- making the code readable/maintainable without spaghetti code 🍝
- making it testable (please don’t skip this point 😈 )
Case study
The purpose of this article is to explain how to implement a State Machine by using this library https://github.com/freeletics/FlowRedux, how to integrate it with Jetpack Compose and last but not least, how to write tests on it.
Our case of study will be a small app which allows the user to type a Github username and fetch the related repositories list, so our domain layer will expose this Interface:
interface GithubRepository { | |
suspend fun repositoryByOwner(owner: String): Either<AppError, List<Repository>> | |
} |
Otherwise from a UI point of view, our app will be composed of a TextField, a confirm Button and a LazyColumn, like this snippet:
Column { | |
Row { | |
TextField( | |
placeholder = { Text(text = "Owner") }, | |
value = contentState.owner, onValueChange = { /* On type */ } | |
) | |
Button( | |
content = { Text(text = "OK") }, | |
onClick = { /* Confirm */ } | |
) | |
} | |
LazyColumn { | |
// item {} to render items | |
} | |
} |
Now we need to find better glue for the UI and the domain layer: a stage machine. My suggestion is to start thinking about the states, which set of states will represent my UI? I have thought of three of them:
- Content state: where basically the TextField is valorised with the username and the LazyColumn with the list of repositories (LazyColumn works like a RecyclerView)
- Loading State: used when the app is making the HTTP request
- Error State: is the case something goes wrong
Now events/actions which can manipulate our state:
- The user is typing the username: in compose exists the concept of unidirectional data flow (read more here), so we need to persist the TextField’s value inside our state.
- Confirm button tapped: the event is triggered when a user taps on the search button.
- User prompts a retry: triggered by the retry button in the Error State.
If we draw the state machine it should be something similar to this:
App’s State Machine
From a code point of view, these could be our States and Actions:
sealed interface GithubState { | |
data class Load(val owner: String) : GithubState | |
data class Error(val e: Throwable, val owner: String) : GithubState | |
data class ContentState( | |
val repositories: List<Repository> = emptyList(), | |
val owner: String = "", | |
) : GithubState | |
} | |
sealed interface GithubAction { | |
object Confirm : GithubAction | |
object RetryLoadingAction : GithubAction | |
data class TypeOwner(val input: String) : GithubAction | |
} |
States & Actions
The State Machine
Now the fun part, we are going to implement the state machine and we need to define the supported actions for each of these states. FlowRedux will help us in this iteration.
FlowRedux is a library which allows you to create a State Machine where for each State we can declare (in a DSL way) which actions are supported. Each action is able to do logic and manipulate the state in different ways:
- override the state with a new one
- mutate the state properties without changing the type
- keep the same state without changes
Look at the sample below.
@ViewModelScoped | |
class GithubStateMachine @Inject constructor( | |
private val githubRepository: GithubRepository | |
) : FlowReduxStateMachine<GithubState, GithubAction>( | |
initialState = GithubState.ContentState(owner = "", repositories = emptyList()) | |
) { | |
init { | |
spec { | |
// spec | |
} | |
} | |
} |
Job Offers
Basically, we are creating a FlowReduxStateMachine with a state based on GithubState and a set of actions based on the GithubAction, then we need to define the initialState, which is going to be the initial state, in our case is the ContentState (where input and repositories are empty).
Now we need to describe the State Machine behaviour inside the spec {}, FlowRedux allows you to define different behaviours for any actions based on the current state.
Starting from the ContentState:
spec { | |
// ContentState | |
inState<GithubState.ContentState> { | |
on { action: GithubAction.TypeOwner, state: State<GithubState.ContentState> -> | |
state.mutate { copy(owner = action.input) } | |
} | |
on { _: GithubAction.Confirm, state: State<GithubState.ContentState> -> | |
val owner = state.snapshot.owner | |
if (owner.isNotBlank()) { | |
state.override { GithubState.Load(owner = owner) } | |
} else { | |
state.noChange() | |
} | |
} | |
} | |
//... | |
} |
In this state, we are saying that every time the UI emits:
- GithubAction.TypeOwner we mutate the ContentState in order to persist the user’s input.
- GithubAction.Confirm we override it with the LoadingState which contains the username typed by the user (if it’s not empty, otherwise we don’t do anything).
ContentState’s actions
And the Load state?
inState<GithubState.Load> { | |
onEnter { state: State<GithubState.Load> -> | |
val owner = state.snapshot.owner | |
githubRepository.repositoryByOwner(owner = owner).fold( | |
ifLeft = { | |
GithubState.Error(Throwable("Fail"), owner = owner) | |
}, | |
ifRight = { | |
GithubState.ContentState(repositories = it, owner = owner) | |
} | |
).let { state.override { it } } | |
} | |
} |
It’s a bit different because we are using the onEnter {} function, this function is called every time the state transits to Load, and this is perfect because we can call the GithubRepository and get the repositories list.
Loading and Error States actions
In case we have a success we override the state with the ContentState otherwise we switch to an Error state, where basically we can retry the call:
// Error | |
inState<GithubState.Error> { | |
on { _: GithubAction.RetryLoadingAction, state: State<GithubState.Error> -> | |
state.override { GithubState.Load(owner = state.snapshot.owner) } | |
} | |
} |
How to integrate the StateMachine on Jetpack Compose
The state machine is ready, now we need to attach it to our Composable UI. There are many options, I have decided to encapsulate the State Machine inside a ViewModel and pass it to the UI.
Basically, I have an abstract AbsStateViewModel which exposes the state and allows the emitting of the actions by using the viewModelScope (because the dispatch method of the FlowReduxStateMachine<S, A> is a suspend function).
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) | |
abstract class AbsStateViewModel<State: Any, Action: Any>( | |
private val stateMachine: FlowReduxStateMachine<State, Action> | |
): BaseViewModel() { | |
@Composable | |
fun rememberState() = stateMachine.rememberState() | |
fun dispatch(action: Action) = viewModelScope.launch { | |
stateMachine.dispatch(action = action) | |
} | |
} |
so I have created a GithubViewModel which extends this class:
@HiltViewModel | |
class GithubViewModel @Inject constructor( | |
githubStateMachine: GithubStateMachine | |
): AbsStateViewModel<GithubState, GithubAction>(githubStateMachine) |
And finally, I can pass the ViewModel inside the Composable function, this is the result:
@Composable | |
fun RepoInfoScreen( | |
githubViewModel: GithubViewModel | |
) { | |
val uiState by githubViewModel.rememberState() | |
Column( | |
modifier = Modifier | |
.fillMaxSize() | |
.padding(16.dp) | |
) { | |
when (uiState) { | |
is GithubState.ContentState -> { | |
Column { | |
val contentState = (uiState as GithubState.ContentState) | |
Row( | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(bottom = 16.dp), | |
verticalAlignment = Alignment.CenterVertically, | |
) { | |
TextField(placeholder = { | |
Text(text = "Owner") | |
}, value = contentState.owner, onValueChange = { | |
githubViewModel.dispatch(GithubAction.TypeOwner(it)) | |
}) | |
Button( | |
modifier = Modifier.padding(start = 16.dp), | |
onClick = { githubViewModel.dispatch(GithubAction.Confirm) }, | |
content = { Text(text = "OK") } | |
) | |
} | |
LazyColumn { | |
items(contentState.repositories) | |
} | |
} | |
} | |
is GithubState.Error -> Column { | |
Text(text = "FAIL") | |
Button(onClick = { githubViewModel.dispatch(GithubAction.RetryLoadingAction) }) { | |
Text(text = "Retry") | |
} | |
} | |
is GithubState.Load -> { | |
Text(text = "Loading stuff") | |
} | |
null -> Unit | |
} | |
} | |
} |
Testing
Hey! Where are you going? Now your state machine is ready but you need to write tests as well! It’s pretty simple, given the state’s updates travel on a Kotlin Flow you need a library which helps you to test the emitting, I suggest https://github.com/cashapp/turbine which fits our scopes.
I have also used https://github.com/mockk/mockk for the mocking and if you are interested I wrote an article on it as well.
In this test for example we are testing the username typing:
@Test | |
fun `Test typing`() = coroutineTestRule.scope.runTest { | |
// When / Then | |
stateMachine.state.test { | |
stateMachine.dispatch(GithubAction.TypeOwner("mcatta")) | |
assertEquals("", (awaitItem() as GithubState.ContentState).owner) | |
assertEquals("mcatta", (awaitItem() as GithubState.ContentState).owner) | |
} | |
} |
I’m testing the typing
in this other one, we are testing the behaviour in the case in case the API call fails:
@Test | |
fun `Test confirm upon a failure`() = coroutineTestRule.scope.runTest { | |
// Given | |
coEvery { githubRepository.repositoryByOwner(any()) } returns Either.Left(AppError.Network) | |
// When / Then | |
stateMachine.state.test { | |
stateMachine.dispatch(GithubAction.TypeOwner("mcatta")) | |
stateMachine.dispatch(GithubAction.Confirm) | |
assertIs<GithubState.ContentState>(awaitItem()) // Initial State | |
assertIs<GithubState.ContentState>(awaitItem()) // State after typing | |
assertIs<GithubState.Load>(awaitItem()) // Confirm Action | |
assertIs<GithubState.Error>(awaitItem()) // Error upon API failure | |
coVerify { githubRepository.repositoryByOwner(eq("mcatta")) } | |
} | |
} |
I’m testing the search upon a failure
Conclusions
FlowRedux is a very cool library, is it the best solution? Maybe yes or maybe no, we love the “it depends” sentence, in past I worked a bit also with MVI pattern, and my feeling is that it works more or less in the same way but we have more control because we can easily define which actions are available for a specific state and have a better hierarchy. Another pro is related to the declaration, the DSL syntax simplifies the code and makes it more readable and maintainable.
I hope you have enjoyed reading this article! Please leave 1/2/tons of 👏 if you liked it, feedbacks are welcome as well!
You can also find the full source code here 👇.
This article was originally published on proandroiddev.com on October 27, 2022