Simple MVI Framework for Jetpack Compose

A simple way to manage state in your Jetpack Compose apps

State management is one of the most important features of any app. Choosing the right architecture from the get go is important as architectural changes become very expensive once the app has been built. Thefore, making sure the architecture we choose is able to support the app and scale  as the app evolves is crucial.

Architecture patterns have evolved over the years, with the most prominent ones used in Android being MVP (Model-View-Presenter), MVVM (Model-View-VIewModel) and MVI (Model-View-Intent). MVP no longer enters the discussion nowadays as it's been superseded by MVVM and MVI, and the choice betwen MVVM and MVI is mostly a matter of personal preference. I prefer MVI because it enforces a more structured approach to state management, the state is only updated in the reducer, with all intents (user input or external actions) being funneled through a pipeline where they are processed in sequence. MVVM is less structured and I find that updating the state pretty much anywhere in the viewmodel makes understanding and debugging the flow harder.

A MVI premier

In MVI there are 3 architectural components:

It's important to note that in MVI the state is immutable, and that MVI adheres to unidirectional data flow. This can be visualized with this diagram:

The basic flow is as follows:

The cycle repeats indefinitively. This architecture provides a clear separation of concerns: the view is responsible for rendering the UI, the intent is responsible for carrying the actions, and the model is responsible for the business logic.

The MVI scaffolding

We will start by creaiting the basic scaffolding for the MVI framework. The solution that will be described here is for Android and tailored to Jetpack Compose apps, but the principles can be applied to any app, mobile or otherwise.

We will base the model on Android's ViewModel, as this solution integrates well with the Android framework and is lifecycle aware, but again, this is not a requirement and other solutions are equally viable.

To create the scaffolding, we need the following pieces:

As this solution is tailored to Jetpack Compose, we will use a MutableState for the model. For the pipeline, we will use a MutableSharedFlow that will feed the reducer. While not strictly necessary, I also like to define marker interfaces for the state and the actions. Let's see the code for our MVI scaffold:

With this scaffold we can now create a simple app, so we will do so, we will create the canonical architecture of your choice sample app, a counter with 2 buttons, one to increase and one to decrease the counter.

The basic sample app

For our sample app, we need the following pieces:

Let's start by defining our state. For this very simple example, our state just needs to hold a single property, the current counter value

Next we will define the actions. Four this example, we will only have two, one to increment the state and one to decrement it:

Now that we have the state and the actions, we can build our reducer, which is responsible for generating a new state based on the current state and the action, Let's see the code:

We have only 2 pieces left, the ViewModel and the UI. Let's create our viewModel first:

All we have left is the UI which I'll gloss over because the details on how we actually render the state into UI does not matter when it comes to the MVI framework. Here's a simple UI to show the counter and 2 buttons to increment/decrement it:

With this we have our basic MVI scaffold and a sample app to exercise it. What we are missing from the solution is handling asynchronous (or long running) operations, as our reducer is updating the state synchronously. Next we will see how we can augment our MVI framework to support asynchronous work.

Handling asynchronous work

To handle asynchronous work in the MVI framework we will add a new concept, a Middleware. The Middleware is a component that is inserted in the MVI pipeline and can execute actions asynchronously. The Middleware will usually emit actions of its own at the start, during and end of its work (for instance, if we have an action that requires a network call, the Middleware may emit an Action to indicate a network load has started, may emit additional actions to update a progress indicator on the network load, and may emit a final loaded action when the network load completes).

As we did for the other components, we will create a base class for the Middleware:

Next let's see how we have to update our MviViewModel to insert the Middleware in the MVI flow:

The idea with this approach is that we will have a set of Middlewares, each responsible for part of the business logic of the app; each Middleware will observe the Actions from the MVI pipeline and, when the one that it is responsible for is emitted, it will start its asynchronous operation. On a large app we could split the screen into sections, each section handled by a separate Middleware, or we could separate the Middlewares by the business logic they perform. The idea is to have small, tailored Middlewares that perform only one or a small set of actions each, instead of a single, large Middleware that handles all asynchronous work.

Updating the counter app

With the Middleware and updated MviViewModel the MVI framework is complete, but things become easier to understand with an example, so we will add a button to our Counter screen to generate  a random value for the counter. We will pretend that generating this random value is a long running process that needs to run on a background thread, so we will create a Middleware for this operation. And because this is a long running operation, we will show a progress indicator while the work in being executed.

We'll start by updating our counter state, to include the loading indicator:

Next we need a new action to generate the random counter value, so we will add it to the sealed hierarchy. Likewise, when the number is ready, we need to update the state, so we need another action to trigger the update. For this second action we have a payload, the randomly generated counter value, so we will use a data class. And finally we want to display a loading indicator while the background task is running, so we will add a third action to show the progress indicator:

Next we will create the Middleware, which will be responsible for generating the random number. We will emit a loading action when we start, and the CounterUpdated action at the end. We will simulate the long operation by using a delay:

This is all there is to the CounterMiddleware.  Next we need to update the reducer to handle the additional actions that we defined earlier. The reducer does not have to handle all actions, the GenerateRandom action is only handled at the middleware, so that one will be a no-op. Let's see the code:

Next we need to update the viewmodel to provide the middleware to the base class and add a new method to handle the generate random number action. Let's see the updates:

This pretty much concludes the example. The last piece is to update the UI to offer a trigger to generate a random number, and to show a progress indicator when the app is busy with the long running operation. The code below shows one possible implementation:

This concludes this article. An implementation of this framework, with dependency injection and with a dedicated coroutine scope is available on this GitHub repo, with the MVI framework located here.