← Back to list

MVVM + MVI: A Practical Guide to Unidirectional Data Flow in Android

My journey into mobile development began in an unusual way. In early 2024, I landed my dream internship at a major tech company and with…

Débora Deotti · 2025-08-31 13:23 · 20 claps · 7.9 min read
#android #mvvm #mvi #android-architecture #unidirectional-data-flow
Open on Medium ↗
Wiki topics: 📱 · Mobile Development 🏛️ · Architecture

MVVM + MVI: A Practical Guide to Unidirectional Data Flow in Android

My journey into mobile development began in an unusual way. In early 2024, I landed my dream internship at a major tech company and with that came amazing opportunities. To this day, it’s hard to believe how unbelievably lucky I was. One of those opportunities was the freedom to choose my own path.

I remember having an online meeting with an engineering director and he asked which area I would prefer to be allocated to: front-end, back-end, or mobile. I — a chemical engineering student, a self-taught developer, who had only had previous experience with front-end (seriously, even my personal projects were web-based) — answered: mobile.

Looking back now, that was crazy. Even more so considering I was set to graduate at the end of the year, with the goal of securing a full-time offer in about 10 months. It could have gone terribly wrong. But I trusted myself with the challenge, and I am so glad I did. This decision was the best I could have made for my career, and not because I don’t love JavaScript and React — I still do, I swear! It made me a better software engineer because it forced me to transfer my knowledge from one stack to another, meaning I had to truly understand the concepts that transcend technologies and platforms. Coming from an unusual background, this was the ultimate lesson I had to learn about software: it is beautiful in any language because it is build upon the very same core principles, just implemented in different forms.

One of the things I was delighted to discover during my learning journey was that the Unidirectional Data Flow (UDF) I knew from the React world was the foundation of MVI, one of the modern patterns I was learning in Android. In this article, I want to share that journey. Building upon the robust architecture created by the brilliant engineers who came before me, we’ll demystify the MVVM and MVI patterns and see how, together, they help us create more predictable, testable, and maintainable code.

A little bit of history

In 2005, the MVVM (Model-View-ViewModel) architecture was invented by Microsoft engineers for the Windows Presentation Foundation platform. The goal was to simplify updating the UI from data and events. At the time, the common approach was MVC (Model-View-Controller), a traditional and robust architectural pattern, but its bidirectional communication between components could lead to complexity and maintenance difficulties in the presentation layer.

Developed by Facebook, React, in 2013, introduced a component-based model and declarative UI rendering. The reactivity introduced by React was soon complemented by Flux, an architecture also developed by Facebook to complement React, which first established the unidirectional data flow. Later, Redux simplified UDF by centralizing the entire application state and introduced middleware to handle asynchronous logic and side effects. In the following years, UDF became a popular approach across various platforms and frameworks. Its principles were incorporated into the architecture of mobile applications, which were already beginning to adopt MVVM.

In the following sections, we’ll explore what this looks like in modern Android development.

MVVM: The Power of Separation of Concerns

MVVM (Model-View-ViewModel) is a widely adopted pattern in mobile development, known for its robustness and scalability. Its main strength lies in the complete separation of concerns:

  • Model: Represents the View’s state and the single source of truth, containing the business rules.
  • View: Is any User Interface (UI) component — like a Composable, Activity, or Fragment — that displays the state and delegates all logic to the ViewModel. The View knows what needs to be shown, but not how the data is processed.
  • ViewModel: Acts as an intermediary between the View and the Model. It is the state holder and carries the UI logic. It exposes data that the View can observe and processes user actions.

MVI: Predictability with a Unidirectional Data Flow

MVI (Model-View-Intent) elevates the concept of UDF (Unidirectional Data Flow) by formalizing that every user interaction is an ‘Intent’. This makes the data flow more explicit and the application’s state more predictable and easier to control. Although it shares similarities with MVVM, MVI innovated by introducing this idea of “Intents” (not to be confused with Android.Intent).

  • Model: Just as in MVVM, it defines the state of the View and is the source of truth for business rules.
  • View: The UI components that observe the state and communicate user actions through Intents.
  • Intent: Represents a user’s or system’s intention to perform an action (e.g., a button click, text input, or an application lifecycle event).

MVVM + MVI: A Powerful Combination

The good news is that these patterns are not mutually exclusive; they can be combined to create even more robust architectures. In many large-scale projects, a hybrid approach is used where MVVM serves as the foundation for the presentation layer, while MVI enhances state management and predictability through a unidirectional data flow.

A key feature of how I apply MVI at work and in my projects is the clear distinction between State, Effect, and Action.

  • State: This is the persistent state of a part of your application. It describes the UI’s condition at a given moment and persists until explicitly updated by an Action.
  • Effect: This is a one-time event that happens once and does not re-occur, even if the screen’s state changes or the View is recreated. Think of a success Snackbar, navigating to another screen, or an API call.
  • Action: This is the trigger for a State change or the emission of an Effect. An Action can be a user click or a lifecycle event from the application itself.

To better understand the role of each, imagine a vending machine.

State: This is the machine’s current condition, what it “knows” at this moment.

  • The stock of each item (e.g., 3 sodas, 5 bags of chips).
  • The amount of money that has been inserted (e.g., $2.50).
  • The machine’s state persists until an action modifies it. The machine doesn’t “forget” that you inserted money.

Action: This is a deliberate command that the user or the system sends to change the state. It is the “intention.”

  • User inserts a $1.00 coin (Action that changes the “money inserted” state).
  • User selects a product (Action that triggers the logic to check the price and stock of the selected item).
  • An action is a one-off event that starts a process.

Effect: This is an event that doesn’t persist; it happens, and then it’s over.

  • The machine dispenses the product into the tray (happens only once for each purchased product).
  • The machine displays a ‘Product Sold Out’ message on the screen (the message appears for a moment and then disappears; it’s not the machine’s permanent state).

Through these concepts, implemented as classes, it’s possible to clearly identify which action produced each effect and each state change, and to establish communication “contracts” between the UI (View) and the entity responsible for controlling its logic (ViewModel).

Code examples

For the code examples, we’ll dive into my personal project, **Quriozzity** — an Android quiz app I built for study purposes. The source code is on my GitHub if you’d like to follow along.

Emitting an UI effect for navigation

First, we’ll break down the flow where the user first enters the app and clicks to start the quiz. This should trigger the navigation from the start screen to the quiz screen.

The Action (the user's intent)

First, we define what the user can do. This action is sent from the UI to the ViewModel. We use a sealed class to represent all possible actions on this screen.

// StartAction.kt

fun interface StartAction {
    fun sendAction(action: Action)
    sealed class Action {
        data object OnClickStart : Action()
        // ... other actions
    }
}

The Effect (the one-time event)

Next, we define the event the ViewModel will send back to the UI. Since the ViewModel shouldn’t handle navigation directly, it tells the UI what to do.

// StartUiEffect.kt

sealed class StartUiEffect {
    data object NavigateToQuiz : StartUiEffect()
}

The ViewModel (the logic)

The ViewModel’s job is to listen for Actions and emit UiEffects. It acts as the bridge between the user's intent and the resulting one-time event.

// StartViewModel.kt

class StartViewModel : ViewModel(), StartAction {
    private val _uiEffect = MutableSharedFlow<StartUiEffect>()
    val uiEffect = _uiEffect.asSharedFlow()
    override fun sendAction(action: StartAction.Action) {
        when (action) {
            StartAction.Action.OnClickStart -> {
                // When the start button is clicked, launch a coroutine
                // to emit the navigation effect.
                viewModelScope.launch {
                    _uiEffect.emit(StartUiEffect.NavigateToQuiz)
                }
            }
        }
    }
}

The UI (sending the Action & handling the Effect)

The StartScreen Composable does two things:

  1. It sends the OnClickStart action when the button is clicked.
  2. It listens for UiEffects from the ViewModel using a LaunchedEffect and executes them.
// StartScreen.kt

@Composable
fun StartScreen(
    onClickStart: () -> Unit, // A callback to perform navigation
    onClickAbout: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: StartViewModel = koinViewModel(),
) {
  // The Effects composable handles the side effects in a separate, dedicated function.
    Effects(viewModel, onClickStart, onClickAbout
    StartContent(
        onClickStart = { viewModel.sendAction(StartAction.Action.OnClickStart) },
        // ... other event handlers
    )
}

@Composable
fun Effects(
    viewModel: StartViewModel,
    onClickStart: () -> Unit,
    onClickAbout: () -> Unit
) {
  // This LaunchedEffect listens for one-time events from the ViewModel.
    LaunchedEffect(Unit) {
        viewModel.uiEffect.collect {
            when (it) {
                is StartUiEffect.NavigateToQuiz -> onClickStart()
                // Other effects would be handled here
            }
        }
    }
}

Tying it all together with navigation

Finally, the AppNavHost builds the navigation graph. It passes the navigation logic (navController.navigate(...)) into the StartScreen as the onNavigateToQuiz lambda. This maintains a clean separation of concerns — the screen doesn't know how navigation works, only that it needs to be triggered.

// AppNavHost.kt

@Composable
fun AppNavHost() {
    val navController = rememberNavController()
    NavHost(navController, startDestination = "start") {
        composable(route = "start") {
            StartScreen(
                onClickStart = { navController.navigate("quiz") }
                // ... other navigation
            )
        }
        composable(route = "quiz") { QuizScreen() }
        // ... other routes
    }
}

Updating UI state

Now, we’ll break down another simple user flow: the user selects an answer to a quiz question. The screen needs to react to this by changing how the selected option looks, highlighting it.

The Action (the user's intent)

When the user taps an answer, the View sends a single, clear Action to the ViewModel.

// QuizAction.kt

fun interface QuizAction {
    fun sendAction(action: Action)
    sealed class Action {
        data class OnClickSelectOption(val questionIndex: Int, val optionIndex: Int) : Action()
        // ... other actions
    }
}
// QuizScreen.kt

@Composable
fun QuizScreen(
    viewModel: QuizViewModel = koinViewModel(),
) {
    val uiState by viewModel.uiState.collectAsState()
    QuizContent(
        uiState = uiState,
        onClickSelectOption = { questionIndex, optionIndex ->
            viewModel.sendAction(
                QuizAction.Action.OnClickSelectOption(
                    questionIndex = questionIndex,
                    optionIndex = optionIndex
                )
            )
        },
        // ... other event handles
    )
}

The State (the Single Source of Truth)

The ViewModel processes this Action and produces a new State. The State class represents everything the UI needs to know to render itself.

// QuizViewModel.kt

class QuizViewModel(...) : ViewModel(), QuizAction {
    // ...
    override fun sendAction(action: QuizAction.Action) {
        when (action) {
            is QuizAction.Action.OnClickSelectOption -> onClickSelectOption(
                questionIndex = action.questionIndex,
                optionIndex = action.optionIndex
            )
            // ... other cases
        }
    }

    private fun onClickSelectOption(questionIndex: Int, optionIndex: Int) {
        val state = _uiState.value
        if (state is QuizUiState.Resumed) {

            // Create a mutable copy of the questions list
            val questions = state.uiModel.quizQuestions.toMutableList()

            // Update the selected option for the specific question
            val question = questions[questionIndex]
            val updatedQuestion = question.copy(selectedOptionIndex = optionIndex)
            questions[questionIndex] = updatedQuestion

            // Emit the new state with the updated list
            _uiState.value = QuizUiState.Resumed(
                state.uiModel.copy(quizQuestions = questions)
            )
            checkButtonState()
        }
    }

    // ...
}

Conclusion

That “no silver bullet” exists is something every software developer is tired of hearing. But, for me, it’s incredible to think about how the work and ideas of so many who came before us have been aggregated and translated into different platforms. By combining the resilient structure of MVVM with the predictability of MVI, we are simply using the best tools the community has developed to build software that is more resilient and enjoyable to work with.

In the end, I landed the job. And, if I may offer you some advice: when given the opportunity, take on a challenge. Trust yourself. And keep your UI predictable!


메타데이터
post_id
bf4f61390204
slug
mvvm-mvi-a-practical-guide-to-unidirectional-data-flow-in-android-bf4f61390204
url
https://medium.com/@debora.deotti/mvvm-mvi-a-practical-guide-to-unidirectional-data-flow-in-android-bf4f61390204
canonical_url
https://medium.com/@debora.deotti/mvvm-mvi-a-practical-guide-to-unidirectional-data-flow-in-android-bf4f61390204
author_url
https://medium.com/@debora.deotti
status
ok
fetched_at
2026-07-20 01:41:37