Part I. Basic steps to implement MVI with the state-machine pattern
This is the first article of the ‘MVI with state-machine’ series that describes the basic architecture and steps to build your logic. Check the other parts of the series for advanced topics:
The source code for the article and the very basic core library is available on GitHub. The library is totally optional — just in case you want to save some time on not writing your own core.
The MVI pattern of architecting modern applications has been getting more and more popular in recent times. There are a lot of articles describing the pattern but to recap let’s see the key points that make up the approach:
- Model — A model holds the representation of the data state and changes it with reducing logic. Data changes are propagated to the view layer as a stream of complete view-states.
- View — The view layer observes user actions and UI-state changes from the model. As a result, it sets the intention for the triggered UI gesture passing it to the model to process. Each UI-state fully describes the complete view state!
- Intent — A representation of user’s gestures that changes the state of the model. View handles widget interactions and provides a stream of gestures to the model through the unified interface.
Key advantages of MVI:
- Single source of truth — one set of data and logic to define complete view-state
- Unidirectional data flow
- Thorough and complete testing of all logic with unit tests
- Friendly to Jetpack Compose view framework
However (as with any technology) there are some downsides that you may come across along with your app growth:
- Too much overkill for simple functions like LCE (Load/Content/Error) display
- Too much reducer logic based on if/else of the current data state that plays badly in complex multi-step scenarios.
- Steep learning curve to grasp the technology
The aim of the article
While there are plenty of MVI frameworks already available, I’d like to show you how you can implement the MVI architecture with a simple and easy way without external dependencies and framework locks. We’ll try to overcome the above drawbacks to give you more freedom to choose technology and to build a cohesive logic and data processing in a “less-opinionated” way. The samples target Android platform but there is no dependency on Android framework both in core components and implementation code so the logic could be used in Kotlin multiplatform projects as shown in part III of this article. The sample projects use Jetpack Compose at the view layer but it is not a restriction. You could use fragment transactions or direct view operations if you like them better.
A state machineis an abstract machine that can be exactly one of a finite number of states at any given time. The machine can change from one state to another in response to some inputs: user gestures, results of asynchronous operations, etc. A state-machine is defined by a list of its states, its initial state, and the inputs that trigger transitions from state to state. The key point for me is that each state may operate only a sub-set of all the gestures that are relevant to it. For example, the music player can’t be
paused (gesture) when it is in the
stopped state. That gives us the ability to narrow down both the amount of logic and input/output data operated by each state and make low coupled and highly cohesive (thus easily maintainable and testable) code.
The basic task – Load-Content-Error
Let’s see how the MVI architecture and the state-machine pattern could be mixed together. Let’s start with a basic example (the full source code is available in
lcefolder of the repository). Imagine we need to implement the classic master-detail view of items with the following screen flow:
Let’s break down business requirements…
We have four application logical states which correspond to screen states for this application:
- Item list – the list of items to load is displayed. User clicks an item to load its contents.
- Loading item – the network operation is running. User waits for operation to complete.
- Item content – the loaded item content is displayed. User may return back to the item list.
- Item load error – the load operation has failed and we have a choice to retry load or to quit the application.
States and transitions
Each machine state may transition to another machine state as a result of
Gesture or state’s internal logic. Let’s take a look at which
Gestures Each machine state processes and how they transition to next machine states:
You could also create a state-diagram to represent your states and transitions:
The diagram above has two types of inter-state transitions:
Red– user Intentions: clicks, swipes and other interactive `Gestures` that are originated by application user.
Blue– transitions made by application logic: content display, errors, etc.
Each machine state should be able to:
- Update the
Ui-Stateof your application.
- Process some relevant user interactions —
- Hold some internal data state
- Transition to another machine state passing some of the shared data between
Now that we have our state requirements, let’s define the role of the state-machine:
- Hold the active machine state
- Delegate gesture processing to the current state
- Transition between states
- Propagate UI-state changes to the outside world
- Clean-up all resources on shutdown
Base components implementation
Let’s combine the state and the machine to see how do they relate to each other. We generalize gesture and UI-states hierarchies as
U sealed class hierarchies accordingly.
The state-machine implements a simple interface with the following methods:
process(gesture: G)— called by view upon user action. Delegated to current state.
clear()— called by view to cleanup resources. Like in
setMachineState(machineState: CommonMachineState<G, U>)— called by the active state to transition to the new one.
setUiState(uiState: U)— called by the active state to update view.
Optional: the state-machine interface above, being a bridge between two worlds, is divided into
MachineInput interfaces to be called by internal state and outside world. Just in case you want to go with a minimal interface available for your application parts.
Let’s create a basic implementation:
The concrete state machine implementation should also provide a way to update the view with a new UI state. For example the
exports UI state changes through
uiState shared flow:
The state and the state lifecycle
The base state class works with the state-machine instance and accepts delegated calls from it. The state has three state-machine interaction methods:
doProcess(gesture: G)— called by the state-machine to process a UI gesture.
setUiState(uiState: U)— call from within your state implementation to update UI State.
setMachineState(machineState: CommonMachineState<G, U>)— call from implementation to transition to the new machine state.
and two lifecycle methods:
doStart()— called by the state-machine when your state becomes active.
doClear()— called by the state machine when your current state is about to be destroyed either by replacing the new state or when state-machine is about to be destroyed.
You can check the basic state implementation here. There is no rocket science there — just some template functions to handle state lifecycle. We’ll get into details a bit later.
The state lives between
doClear calls. You could safely call interaction methods and expect gesture processing calls within that period. Make sure to cleanup all your pending operations in
doClear handler. For example, the
CoroutineState provides you the
stateScope coroutine scope that is being canceled in
Note on threading: the library doesn’t provide any threading support and not thread-safe. So it is your responsibility to implement correct thread handling so all state changes happen on the desired thread. The
CoroutineState above creates it’s scope with
Now that we have all basic components in place, let’s implement our application. First of all, let’s create our
UI-state systems using Kotlin sealed classes.
Gestures — events emitted by UI layer and passed to state-machine:
UI-states — data that fully describes what user sees. Emitted by machine:
Let’s get to implementing machine states then.
This is the initializing state of our state-machine. It displays the list of items to load.
The list is hardcoded for this example so we just emit a complete view-state in
doStart() template method.
User may click one item or another producing the
ItemClicked gesture. We handle it in
onItemClicked method by transitioning the machine to the
LoadingState passing selected item id to load.
If users presses back button, he produces the
Back gesture. The state machine is switched to the
TerminatedState which terminates the activity.
Data ‘loading’ state
This state pretends to be running an asynchronous operation by starting a coroutine with delay.
We display a loading spinner in
onStart() template and run the coroutine.
Given the hardcoded item ID the state finishes either in
toError handler. Thus we transition to either the
ContentState passing ‘loaded’ item data or to the
ErrorState passing the error and the failed item ID to implement retry (see below).
If user clicks
Back while waiting for result the machine is switched back to the item list. We subclass the
CoroutineState here so our
stateScope is canceled when state is shut down.
Item contents state
This one is very basic. The state emits the loaded data passed to the constructor and handles
Back to return to item-list.
The last state worth mentioning is the error state that displays a popup with retry and fail options.
We dispatch relevant user gestures by calling the appropriate handler and switching the state-machine to the new state. Worth to mention the
onRetry handler — the new
LoadingState is created there that will restart the item loading procedure from scratch.
Wiring with the application
Now that we have all states implemented let’s feed them to the state-machine and connect with the view subsystem. We need some place to retain the state-machine so let’s wrap it to the jetpack
All we need to do here is:
- to figure out the initial state that machine will start from
- to create a state-machine instance
- to wire ui-state and gesture processing with the outside world
And here is our composable main view that connects to the view-model:
So far so good
I hope I’ve managed to showcase the simplicity of the state-machine pattern in implementing the application logic. It produces a clean and easy to grasp step-by-step logic with well-separated concerns and easy and thorough testing ability (see the source code for details). The pattern also attempts to be as non-opinionated as possible. Each state is a black-box with a defined contract and developers may choose the most suitable tools to implement each one without affecting the other.
The example above is a very basic one. However you could do things a lot more clean by using some of the additional abstracts and code organizing. Jump to Part II to learn more!