Your app is growing. Your code is getting messy. Let’s fix that.

Jetpack Compose makes building Android UI easy. But here is the problem — when your app gets bigger, things get complicated fast. Buttons, loading spinners, error messages… suddenly your code is everywhere.

MVI can help you organize all of this.

In this guide, you will learn:

  • What MVI means (in plain words)
  • Why it works so well with Jetpack Compose
  • How to build a real example, step by step
  • Mistakes you should avoid

Let’s start.

What is MVI?

MVI stands for Model-View-Intent. It has three parts:

LetterMeansSimple Explanation
MModelThe data on the screen right now
VViewYour Compose UI code
IIntentWhat the user wants to do (tap a button, type text, etc.)

MVI is a pattern — a set of rules for how data moves in your app.

The most important rule: data moves in only ONE direction. Never backwards.

How Does Data Move?

Think of it like a circle:

User taps something
UI sends an Intent
ViewModel receives the Intent
ViewModel creates a new State
UI shows the new State
(User taps something again...)

That’s it. Every interaction in your app follows this same circle.

Three Rules You Must Follow

Before writing any code, remember these:

Rule 1: One screen = One state

Every screen has one single state object. Not two. Not five. Just one.

Rule 2: Never change state directly

You don’t edit the state. Instead, you make a copy with the new values. We will see how with .copy() below.

Rule 3: The UI does not think

The UI has one job — show what the state says. It does not calculate. It does not decide. It only displays and sends intents.

Why MVI Works So Well with Compose

Jetpack Compose already watches for state changes. When the state changes, the screen updates automatically.

MVI gives this system a clear structure:

  • You always know where your data is
  • You always know how your data changes
  • You can find bugs faster

They are a perfect match.

Let’s Build Something: A Counter App

We will make a simple screen. It shows a number. The user can:

  • Add 1 to the number
  • Remove 1 from the number
  • Reset the number to zero

Small app, but it teaches you the full MVI pattern.

Step 1: Create the State (Model)

The state holds everything the UI needs to show. For our counter, that is just one number.

data class CounterState(
    val count: Int = 0
)

Why a data class? Because data classes in Kotlin give us the .copy() function for free. This is how we follow Rule 2 — we never change the state directly.

Step 2: Create the Intents (User Actions)

List every action the user can take. We use a sealed interface so we have a fixed list — nothing else can be added later.

sealed interface CounterIntent {
    data object Increment : CounterIntent
    data object Decrement : CounterIntent
    data object Reset : CounterIntent
}

Why sealed interface and data object? This is the modern Kotlin way. Older tutorials use sealed class and object, which still works. But sealed interface is cleaner and more flexible.

Step 3: Create the ViewModel (The Brain)

The ViewModel does three things:

  1. Keeps the current state
  2. Listens for intents
  3. Creates new states
class CounterViewModel : ViewModel() {

    // Private - the UI cannot change this directly
    private val _state = mutableStateOf(CounterState())

    // Public - the UI can only READ this
    val state: State<CounterState> = _state

    // Handle every user action here
    fun onIntent(intent: CounterIntent) {
        when (intent) {
            is CounterIntent.Increment -> {
                _state.value = _state.value.copy(
                    count = _state.value.count + 1
                )
            }
            is CounterIntent.Decrement -> {
                _state.value = _state.value.copy(
                    count = _state.value.count - 1
                )
            }
            is CounterIntent.Reset -> {
                _state.value = CounterState() // Back to default
            }
        }
    }
}

Notice the .copy() calls. We never write _state.value.count = 5. We always create a new copy. This keeps our state safe and predictable.

Step 4: Create the UI (View)

The UI is simple. It reads the state and sends intents. Nothing more.

@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
    val state by viewModel.state

    Column(
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(
            text = "Count: ${state.count}",
            fontSize = 24.sp
        )

        Row {
            Button(onClick = {
                viewModel.onIntent(CounterIntent.Decrement)
            }) {
                Text("-")
            }

            Button(onClick = {
                viewModel.onIntent(CounterIntent.Increment)
            }) {
                Text("+")
            }
        }

        Button(onClick = {
            viewModel.onIntent(CounterIntent.Reset)
        }) {
            Text("Reset")
        }
    }
}

Look how clean this is. The UI has zero logic. It just reads state.count and calls onIntent(). That’s it.

Common Mistakes (Avoid These)

Here are the most common mistakes developers make with MVI:

Too many state objects for one screen

One screen should have ONE state. If your screen has loadingState, errorState, and dataState as separate objects — combine them into one data class.

// Bad - separate states
var isLoading by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var data by remember { mutableStateOf<List<Item>>(emptyList()) }

// Good - one state object
data class ScreenState(
    val isLoading: Boolean = false,
    val error: String? = null,
    val data: List<Item> = emptyList()
)

Putting logic inside the UI

If you write if, when, or math calculations inside your @Composable function — move it to the ViewModel. The UI should only display.

Changing state directly

Always use .copy(). Never modify a value inside the state object.

When Should You Use MVI?

Use MVI when:

  • Your screen has many user actions
  • Your screen shows data from different sources
  • You want to find and fix bugs easily
  • Your team is growing and you need clear code structure

Maybe skip MVI when:

  • Your screen is very simple (just showing static text)
  • You are building a quick prototype

But even for simple screens, MVI keeps your project clean as it grows. A little extra work now saves a lot of time later.

Quick Summary

ConceptWhat It Does
State (Model)Holds all data the UI shows
IntentDescribes what the user wants to do
ViewModelReceives intents, creates new states
UI (View)Shows the state, sends intents

The golden rule: the UI is just a mirror of your State. Nothing more.

What’s Next?

Once you are comfortable with this pattern, you can explore:

  • Adding side effects (like API calls or navigation)
  • Using StateFlow instead of mutableStateOf for more control
  • Handling one-time events (like showing a snackbar)

But for now, practice the basics. Build the counter app. Then try adding more features to it.

Happy coding!