MVI with state-machine. Basics | by Nikolai Kotchetkov | Medium

Part I. Basic steps to implement MVI with the state-machine pattern

“Learning strait” to adopt the state-machine MVI architecture by Ulyana Kotchetkova

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.

Introduction

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.

State-machine definition

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:

Load-Content-Error flow
Load-Content-Error flow of the master-detail application

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:

LCE state diagram
LCE state diagram

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-State of your application.
  • Process some relevant user interactions — Gestures ignoring irrelevant
  • 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 G and U sealed class hierarchies accordingly.

State-machine class diagram
State-machine class diagram

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 onCleared of Jetpack’s ViewModel.
  • 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 MachineOutput and 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:

State machine base

The concrete state machine implementation should also provide a way to update the view with a new UI state. For example the FlowStateMachine
exports UI state changes through uiState shared flow:

The state machine that exports UI updates through the Flow interface

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 doStart and 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 doClear:

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 Dispatchers.Main.immediate.

App Implementation

Now that we have all basic components in place, let’s implement our application. First of all, let’s create our Gesture and UI-state systems using Kotlin sealed classes.

Gestures — events emitted by UI layer and passed to state-machine:

LCE app gestures

UI-states — data that fully describes what user sees. Emitted by machine:

LCE app UI-states

Let’s get to implementing machine states then.

Item list

This is the initializing state of our state-machine. It displays the list of items to load.

Item list state

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.

Item loading state

We display a loading spinner in onStart() template and run the coroutine.

Given the hardcoded item ID the state finishes either in toContent or 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.

Item contents state

Error state

The last state worth mentioning is the error state that displays a popup with retry and fail options.

Item load error state

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 ViewModel:

LCE view-model

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:

LCE view

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!

Leave a Comment