Blog Infos
Author
Published
Topics
, , ,
Published

In this article, we’re going to explore how to implement the MVI (Model–View–Intent) in a Jetpack Compose Android app, using a hands-on example: a simple TODO list.

While the functionality of the app is intentionally minimal — adding, viewing, and deleting items — it serves as a clear foundation for understanding how MVI works in practice.

We’ll walk through:

  • The structure of an MVI-based feature
  • How to model user actions as Intents
  • How to represent and manage the UiState
  • How to write a pure Reducer for state transitions
  • How to handle side effects using Middlewares
  • How to wire everything up in the ViewModel
  • And how the UI layer (the Pane) reacts to state and dispatches new actions

Whether you’re new to MVI or looking to refine your architectural patterns in Compose apps, this guide will help you understand how to build a scalable, testable UI layer with unidirectional data flow.

Let’s dive in.

Why MVI?

MVI is a unidirectional architecture pattern that simplifies UI state management by enforcing a single source of truth and predictable state transitions. It’s particularly well-suited for modern Compose-based UIs because:

  • It makes state mutations explicit.
  • Side effects are handled in a controlled and testable way.
  • The UI becomes a pure function of state.
Disclaimer: Class Naming

For simplicity, this sample uses very generic class names like IntentUiState, and ReducerIn a production app, it’s highly recommended to scope these to the feature, e.g., TodoIntentTodoUiState, etc., to avoid confusion and improve readability when the codebase grows.

Project Structure

Here’s a quick look at the feature structure used in the app:

feature/
 ├── Intent.kt         // User actions
 ├── UiState.kt        // Current UI representation
 ├── Reducer.kt        // Pure state transformation logic
 ├── ViewModel.kt      // Coordinates everything
 ├── Middlewares.kt    // Side effects (e.g. data fetching)
 ├── Module.kt         // Dependency injection
 ├── Navigation.kt     // Feature navigation
 ├── Pane.kt           // UI Composable

This structure promotes clear separation of concerns and keeps business logic decoupled from UI rendering.

Intents

The Intent sealed interface defines all possible user actions and system events that can trigger state changes.

sealed interface Intent {
    data object LoadItems : Intent
    data class AddItem(val value: String) : Intent
    data class DeleteItem(val id: String) : Intent
}

Each intent represents an explicit request to change the application state. For example:

  • LoadItems is dispatched when the screen initializes.
  • AddItem when a new item is submitted.
  • DeleteItem when a user removes a task.

🔍 MVI vs MVVM — Key Distinction

In MVVM, you typically expose a single method for each action (e.g. addItem()), and that method might trigger both the side effect and the state update internally.

In MVI, by contrast, each “action” tends to be represented by multiple intents:

– One intent to initiate the action (AddItem)

– Others to handle its effects or results, like success or error (AddItemSuccessAddItemError)

This makes the flow explicit, traceable, and testable — every state change or side effect is driven by a well-defined intent.

UiState

The UiState data class holds all the information needed to render the screen.

data class UiState(
    val isLoading: Boolean = false,
    val items: List<PendingItem> = emptyList(),
    val error: String? = null
)

This ensures a single source of truth for the UI. Any composable just needs to observe this state to render correctly, reducing bugs and inconsistencies.

Reducer

The Reducer handles pure transformations from one state to another based on an incoming Intent.

 

class Reducer : MviReducer<Intent, UiState> {
    override fun reduce(currentState: UiState, intent: Intent): UiState {
        return when (intent) {
            is Intent.LoadItems -> currentState.copy(isLoading = true)
            is Intent.AddItem -> currentState.copy(
                items = currentState.items + PendingItem(id = UUID.randomUUID().toString(), value = intent.value)
            )
            is Intent.DeleteItem -> currentState.copy(
                items = currentState.items.filterNot { it.id == intent.id }
            )
        }
    }
}

 

One of the main benefits of using a sealed interface with a when expression is exhaustiveness. The compiler ensures that all possible Intent types are handled.

If you introduce a new intent (e.g., EditItem), the compiler will force you to update this reducer, reducing the risk of missing behavior or bugs.

Middlewares

Middlewares are used to handle side effects, like data loading or interactions with repositories.

For example, a LoadItemsMiddleware can trigger Intent.LoadItems and then dispatch a new intent once data is retrieved:

class LoadItemsMiddleware(
    private val repository: PendingRepository
) : Middleware<Intent, UiState> {
   
 override suspend fun process(
    intent: Intent,
    state: UiState,
    dispatch: Dispatch<Intent>
 ) {
     if (intent is Intent.LoadItems) {
     val result = repository.getPendingItems()
     result.onSuccess { items ->
       dispatch(intent = InternalIntent.LoadItemsSuccess(items))
     }.onFailure {
       dispatch(intent =InternalIntent.LoadItemsError(it.message)
     }
   }
}
ViewModel

Disclaimer: I’m aware that using a ViewModel might seem unnecessary in this context, especially from a pure MVI perspective. In this case, I used it solely because I needed a lifecycle-aware class, and ViewModel was the most straightforward and lightweight solution for the job. The focus of this article is on showcasing the intent-reducer-effect flow, not enforcing architectural orthodoxy.

The ViewModel is where the reducer and middleware come together. It receives intents, runs them through middleware, applies reducers, and exposes the final state to the UI.

class TodoViewModel @Inject constructor(
    reducer: Reducer,
    middlewares: Set<@JvmSuppressWildcards Middleware<Intent, UiState>>
) : MviViewModel<Intent, UiState>(
    initialState = UiState(),
    reducer = reducer,
    middlewares = middlewares
)
Pane (Composable UI)

Finally, the Pane is the screen itself. It reacts to UiState changes and sends user actions as Intents:

@Composable
fun TodoPane(
    state: UiState,
    onTextChanged: (String) -> Unit,
    onAddClicked: () -> Unit,
    onDeleteClicked: (TodoItem) -> Unit,
    modifier: Modifier = Modifier
) { }

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

Jobs

Try It Yourself

The full code is available here: 🔗 https://github.com/matiasdelbel/ui-mvi

Wrap-up

Using MVI in Android Compose apps leads to predictable, testable, and scalable UI architecture. Even a simple TODO app benefits from the discipline of unidirectional data flow and clean separation of logic.

This article was previously published on proandroiddev.com.

Menu