Blog Infos
Author
Published
Topics
, , , ,
Published

Image credit Sardor A on Figma

The MVI (Model-View-Intent) architecture offers a well-structured approach for scalable, robust, and testable ui state management in Android applications. It emphasises clean code and a clear separation of concerns by dividing the application into three main components — Model, View, and Intent — together, they form a loop: Intent -> ViewModel -> Model -> View defining a unidirectional data flow. The distinct roles provided by this architecture pattern contribute to easier understanding, and maintenance of ui state. At its core, MVI isn’t just an architectural pattern-it’s a fundamentally reactive system designed to respond fluidly to changes. This reactivity is one of its defining characteristics and greatest strengths.

  • Unidirectional data flow: means data flows in a single direction — from the Model to the View and back as Intents. This ensures clarity, predictability, and ease of maintenance in the architecture.
  • Separation of concerns: means distinct roles for Model, View, and Intent components. The Model manages the state, the View handles UI rendering, and the Intent captures and communicates user actions.
  • Immutability: ensures that the Model’s state remains unchanged once set. This guarantees predictability, eliminates unexpected side effects, and promotes a stable and reliable application state.
  • Reactive: The ui automatically updates when the state changes

The architecture breaks down into three harmonious components that work together in a reactive flow:

  • The Model serves as the single source of truth — a snapshot of your application’s state at any given moment. When this state changes, it triggers a cascade of reactive updates throughout the system. The UI automatically updates when the state changes, highlighting this core reactive principle.
  • The View reactively renders what the user sees based on the current model state. It subscribes to state changes and automatically transforms to reflect them without any imperative update calls. This reactive rendering is what makes MVI so powerful-the view is always in sync with the state.
  • The Intent completes the reactive circuit, capturing user interactions and feeding them back into the system to create new states. This creates a continuous feedback loop: user actions trigger intents, intents produce new states, and new states trigger UI updates.

When we talk about MVI being reactive, we mean that the entire system is built around responding to changes automatically. The UI doesn’t need to be manually updated when data changes-instead, it automatically reflects the current state. This reactivity creates a dynamic, responsive application that feels alive to users.

In native android development, the bulk of the MVI implementation is placed in a ViewModel class. The following is a simple approach to implementing the MVI pattern:

  1. The UI state we intend to model would be implemented as an immutable Kotlin data class where its fields hold the states we would want to display in our view.
  2. The StateFlow is the reactive glue that binds the entire architecture together. This observer object wraps the model and notifies the view of changes in order for it to reflect the new state. This reactive pipeline ensures that any state change automatically propagates to the UI.
  3. Our intents for now can be implemented as a public function in the ViewModel. These function should have no return value to ensure that the view only receive state updates from the observer. Similar to the way objects are first class programming language construct and can be passed around through reference, likewise functions thanks to method reference. We take advantage of this to pass around intents to the ui node that uses it. We don’t have to cook up a complicated class to model an intent which would require additional implementation of an intent handler.

Below is a template of implementing an MVI pattern in a ViewModel.

class ScreenViewModel() : ViewModel() {
  private val _uiState: MutableStateFlow<MyModel> = MutableStateFlow(MyModel(...)) // private observer object
 
  val uiState: StateFlow<MyModel> = _uiState // observer object exposed as an immutable instance
 
  fun doUpdateOnState(...) { ... } // public function serves as an intent
  fun doAnotherUpdate(...) { ... }
}

data class MyModel(...)

New states are produced in the ViewModel and then consumed by the view observing the uiState. Notice how intents are passed on to composables screens as call backs that triggers a state change.

@Composable
fun MyScreen(viewModel: ScreenViewModel) {
  val uiState: MyModel = viewModel.uiState.collectAsStateWithLifeCycle() // consumes the state produced in the viewModel
 
  MyScreenContent(
    uiState = uiState,
    doUpdate = viewModel::doUpdateOnState, // intent to do update
    doAnotherUpdate = viewModel::doAnotherUpdate // intent to do another update
  )
}

This reactive pattern comes to life through Kotlin’s StateFlow:

private val _uiState: MutableStateFlow<MyModel> = MutableStateFlow(MyModel(...))
val uiState: StateFlow<MyModel> = _uiState

On the UI side, this reactivity is expressed through a collect operation consuming new states as they are produced from the viewModel.

val uiState = viewModel.uiState.collectAsStateWithLifeCycle()

This single line establishes a reactive connection that automatically refreshes the UI whenever the state changes. There’s no need for manual refresh calls or complex update logic-the system is inherently reactive.

Case Study

Let’s take a more practical approach and implement an MVI pattern to manage the state of a screen. Below is a screen that allows users to select from the given options an answer to the displayed question.

Image credit — Compose samples Jetsurvey. Click here to view the full implementation

The screen consists of the following states:

  1. A question
  2. A list of options
  3. A question count
  4. A selection indicator
  5. Enable/disable button

In addition, the screen provides input for the following user action:

  1. Get the next question
  2. Get the previous question
  3. Select an option
  4. Close/end action These actions are used to communicate the user’s intents to the application.

The model that holds the state for the screen can be implemented as such

data class UiState(
  val questionCount: Int,
  val totalQuestion: Int,
  val question: Question,
  val userSelection: Option?
) {
  val hasNext: Boolean = questionCount < totalQuestion
  val hasPrevious: Boolean = questionCount > 1
}

As mentioned earlier for simplicity, we describe the intents using public functions and these functions have no return value. We enumerate the functions we would be implementing in the ViewModel below.

fun next() // loads next question
fun previous() // loads previous question
fun onOptionSelected(selection: Option) // activates indicator for selected item

Each function when called in the view triggers a new state to be emitted for the view to consume, and with this our screen is predictable and testable since each user interaction produces a new immutable state that can be compared against during tests.

@Composable
fun QuestionScreen(
modifier: Modifier = Modifier,
onDone: () -> Unit
) {
BackHandler { /* Do nothing */ }
val viewModel = QuestionViewModel(getQuestions())
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
QuestionScreenContent(
uiState = uiState,
onClickNext = viewModel::next,
onClickPrevious = viewModel::previous,
onOptionSelected = viewModel::onOptionSelected,
onDone = onDone
)
}

Note: Not all screen component with state can be managed with MVI. Some are managed in a separate state holder class, some are internal state managed in the composable itself — take the progress indicator in the above screen for example. State not really dealing with business logic shouldn’t be managed using MVI.

Beyond Basic Reactivity

The case study above used a simple implementation of a model based on our screen requirement. There are quite a few ways to write a model implementation for MVI pattern — an implementation informed by screen requirement — take for example a screen with a loading and error state. Usually it is most simply achieved using sealed class hierarchy — all this being said, you have the choice of implementing your own model differently.

// A Model that accounts for loading and error state
internal sealed class UiState {
object Loading : UiState()
@Immutable
class Content(val myModel: UiModel) : UiState()
@Immutable
class Error(val error: ErrorUiModel): UiState()
}
view raw mvi_model.kt hosted with ❤ by GitHub

The view consumes this new type a bit differently like so

@Composable
fun MyScreenContent(uiState: UiState) {
when(uiState) {
is Loading -> LoadingScreen()
is Error -> ErrorScreen(uiState.error)
is Content -> ContentScreen(uiState.myModel)
}
}
fun LoadingScreen() { /* implementation block */ }
fun ErrorScreen(error: ErrorUiModel) { /* implementation block */ }
fun ContentScreen(content: UiModel) { /* implementation block */ }

It is important to note that this type of model is mutually exclusive — it guarantees that all three states cannot happen all at once but one at a time which helps prevent a common bug in UI state rendering.

Rewriting an Intent Implemention

The intent implementation can also be modified by adding a reducer/handler which is a public function in the viewmodel that then calls private implementation (helper functions) to perform an action using when expression to branch to corresponding action.

Class HomeScreenViewModel() : ViewModel() {
/*
* Some class properties
*/
// our reducer/handler
fun onHomeAction(action: HomeAction) {
when (action) {
is HomeAction.CategorySelected -> onCategorySelected(action.category)
is HomeAction.TopicFollowed -> onTopicFollowed(action.topic)
is HomeAction.HomeCategorySelected -> onHomeCategorySelected(action.category)
is HomeAction.ToggleTopicFollowed -> onToggleTopicFollowed(action.topic)
}
}
private fun onCategorySelected(category: CategoryInfo) { ... }
private fun onTopicFollowed(topic: TopicInfo) { ... }
private fun onToggleTopicFollowed(topic: TopicInfo) { ... }
private fun onHomeCategorySelected(category: HomeCategory) { ... }
}

In order for this setup to work, we then define a sealed interface hierarchy corresponding to each action whose subclass properties are used to hold arguments that would then be passed on to these actions through the reducer/handler from the view.

@Immutable
sealed interface HomeAction {
data class CategorySelected(val category: CategoryInfo) : HomeAction
data class HomeCategorySelected(val category: HomeCategory) : HomeAction
data class TopicFollowed(val topic: TopicInfo) : HomeAction
data class ToggleTopicFollowed(val topic: TopicInfo) : HomeAction
}
view raw mvi_intent.kt hosted with ❤ by GitHub

Unlike our first implementation of an intent that requires us to pass the entire actions using method reference in the view, here we only need to pass the reducer/handler. Then the responsibility of deciding what action needs to be called is scoped to the caller.

Here is how it looks from the view

@Composable
fun HomeScreen(viewModel: HomeScreenViewModel, onNavigate: (String) -> Unit) {
HomeContent(
modifier = Modifier.padding(contentPadding),
onHomeAction = viewModel::onHomeAction,
onNavigate = onNavigate,
)
}

HomeContent composable is now responsible for deciding which action to call by instantiating any of the following objects then calling onHomeAction with the instantiated object

HomeAction.CategorySelected(category = CategoryInfo())
HomeAction.HomeCategorySelected(category = HomeCategory())
HomeAction.TopicFollowed(topic = TopicInfo())
HomeAction.ToggleTopicFollowed(topic = TopicInfo())

See it in action below!

@Composable
private fun HomeContent(
modifier: Modifier = Modifier,
onHomeAction: (HomeAction) -> Unit, // HomeAction is a sealed class hierarchy
onNavigate: (String) -> Unit,
) {
val homeCategory = HomeAction.HomeCategorySelected(CategoryInfo())
onHomeAction(homeCategory) // triggers a state change
}

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

No results found.

Jobs

Even with this added complexity, the core reactive principles remain intact. The reducer simply provides a more organized way to process intents and produce new states, while the reactive flow from state to UI remains unchanged.

This implementation makes it easy to build iteratively — whatever changes we need to make to our intents (whether adding new ones or removes existing ones) does not require rewrites in many places unlike our first implementations; we just register new actions in the handler, then instantiate a different object depending on our case when calling the handler.

To conclude this article, the most elegant aspect of MVI is how it creates a complete reactive circuit:

  1. The Model emits state
  2. The View consumes state and renders UI
  3. User interactions with the View generate Intents
  4. Intents are processed to create new Models
  5. The cycle continues reactively

This unbroken reactive loop ensures that your application is always in sync with user actions and backend data. It’s not just about responding to changes-it’s about creating a system where changes flow naturally through a pre-defined reactive pathway.

This article was previously published on proandroiddev.com.

Menu