Opening

MVI, MVVM, MVC. If you have been in Android development for a while, you have probably seen passionate debates about which state management pattern is best. People will argue endlessly about whether MVI is superior to MVVM, or why MVVM is a stepping stone to MVI, or how Compose changes everything. And here is what I want to tell you upfront: there is no winner. There is no best pattern. Each one makes a different trade-off, and understanding those trade-offs — really understanding them at the runtime level — is far more valuable than picking a camp and defending it. So let us dig into what actually happens when your app manages state with each of these patterns.

What State Management Really Means

Before we compare patterns, let us agree on what state management actually is. Your UI needs to show something. That something is determined by data — the current user, whether a list is loading, whether an error occurred, which tab is selected. State management is the answer to three questions. Where does this data live? How does it change? And how does the UI know when it changed? That is it. Every pattern — MVI, MVVM, MVC, Redux, BLoC, whatever — is just a different answer to those three questions. Different answers come with different trade-offs. There is no free lunch.

MVVM with Multiple Streams

Let us start with MVVM, since it is the most common pattern in Android today. In a typical MVVM setup, your ViewModel exposes multiple observable streams. A StateFlow for the user data. A StateFlow for the loading status. A StateFlow for error messages. Maybe a SharedFlow for one-time events like navigation. Your UI observes each of these independently. When the user data changes, only the part of the UI that displays user data recomposes or rebinds. When loading changes, only the loading indicator updates. The benefit is granularity. Each piece of state is independent, and changes to one do not affect the others. But here is the trade-off: with multiple independent streams, you can end up in an inconsistent state. What if loading is false but user is null and error is also null? Is that a valid state? Your code says yes — each stream is independent. But your UI has no idea what to show. Multiple streams mean you can represent states that should not exist.

MVI with Single State

MVI takes the opposite approach. Instead of multiple streams, you have one. A single state object that contains everything the UI needs. A data class called ProfileState with a user field, an isLoading field, an error field. All in one place. The ViewModel exposes a single StateFlow of ProfileState. The benefit is consistency. You can never have loading equals true and error equals non-null and user equals non-null at the same time — unless you explicitly allow it. You model your states as a sealed class: Loading, Success with data, Error with message. The UI pattern-matches on this sealed class and handles each case. Impossible states become unrepresentable. But here is the MVI trade-off. Every state change creates a new copy of the entire state object. When the loading field changes, the entire ProfileState is re-emitted, even though user and error did not change.

The Recomposition Cost in Compose

This trade-off matters a lot with Jetpack Compose. When your Composable function collects a StateFlow, it recomposes whenever a new value is emitted. With MVVM's multiple flows, you can collect each flow in the specific Composable that needs it. The user name Composable collects only the user flow. The loading indicator collects only the loading flow. Fine-grained reactivity. With MVI's single state, every emission triggers a recomposition check for the entire UI tree that collects that state. Now, Compose is smart — it skips recomposition if the parameters have not actually changed. But this relies on stability. If your state contains unstable types like lists or custom objects without proper equals, Compose cannot skip, and you get unnecessary recomposition. The fix is to use derived state, or to split your single state into focused pieces that individual Composables read. But at that point, you are partially recreating the MVVM pattern inside MVI.

StateFlow vs SharedFlow

Let me clarify a technical point that confuses a lot of developers. StateFlow and SharedFlow are both flows, but they behave very differently. StateFlow always has a current value. When a new collector subscribes, it immediately gets the latest value. It also uses equality checks — if you emit the same value twice, collectors are not notified the second time. This makes StateFlow perfect for state: the UI always has something to show, and identical states do not cause unnecessary updates. SharedFlow has no current value. It only emits to active collectors. If no one is collecting when an event fires, the event is lost — unless you configure a replay buffer. SharedFlow does not do equality checks — every emission is delivered. This makes SharedFlow better for events: navigation commands, snackbar messages, things that should happen once and should not be replayed when the screen rotates. The mistake I see constantly is using SharedFlow for state or StateFlow for events. Use the right tool for the right job.

Derived State

One powerful technique that works with any pattern is derived state. Instead of storing computed values, you derive them from your source of truth. For example, your state has a list of items and a search query. Instead of storing a filtered list separately, you derive it: items dot filter by query. In MVVM, you can use the combine operator to merge multiple flows and produce a derived flow. In MVI, you compute derived properties from your single state object. Derived state has a huge benefit: you can never have an inconsistent filtered list because it is always computed from the actual list and the actual query. The trade-off is computation cost. If your derivation is expensive — say, sorting a large list — you are recomputing it on every emission. In practice, you can mitigate this with distinctUntilChanged or by memoizing the computation.

MVC and Why It Fell Out of Favor

Let me briefly touch on MVC, because understanding why it failed on Android helps you understand the problems MVVM and MVI were designed to solve. In MVC, the Controller — which in Android was the Activity or Fragment — handled both UI logic and business logic. It observed the Model, updated the View, and handled user input. The problem was lifecycle. The Controller was tied to the Android lifecycle. When the screen rotated, the Activity was destroyed, and all your state was lost. You ended up saving state in onSaveInstanceState, restoring it in onCreate, and managing a tangled mess of lifecycle-aware code. MVVM solved this by moving state to a ViewModel that survives configuration changes. MVI refined it further by making state transitions explicit and predictable. The evolution was driven by real pain, not by theoretical preferences.

Intents and Reducers in MVI

If you have not used MVI before, let me explain the full loop. The user performs an action — tapping a button, typing in a search field. This produces an Intent — not an Android Intent, just a sealed class representing the user's intention. LoadProfile, SearchUsers, RetryRequest. The ViewModel receives this Intent and processes it through a reducer: a pure function that takes the current state and the intent, and produces the new state. Current state is Loading, intent is DataLoaded with users — new state is Success with users. Because the reducer is a pure function, it is trivially testable. You do not need a ViewModel instance or any mocking framework. Just call the function with a state and an intent, and assert on the output. This predictability is MVI's real superpower. But the boilerplate cost is real. Every user action needs an Intent class. Every state transition needs reducer logic. For simple screens, this is overkill.

Choosing for Your Specific Case

So how do you actually choose? Here is my framework. If your screen is simple — a list, a detail view, a form — MVVM with two or three StateFlows is perfectly fine. The consistency risk is low because there are not many states to coordinate. If your screen has complex state interactions — multiple data sources, optimistic updates, undo/redo, or states that must always be consistent — MVI's single state and explicit transitions pay for themselves. If your team is large and you need predictability in how state changes happen, MVI's reducer pattern makes code reviews easier because every state transition is explicit. If your team is small and you value speed of development, MVVM's simplicity wins. And there is nothing wrong with mixing patterns. Use MVI for your complex checkout flow and MVVM for your settings screen. Patterns are tools, not religions.

The Compose Factor

Jetpack Compose shifts the calculus slightly. In Compose, state drives the UI declaratively. You describe what the UI should look like for a given state, and Compose handles the updates. This naturally aligns with both MVVM and MVI, but the recomposition model adds nuance. With Compose, you want to minimize the scope of recomposition. Pass only the data each Composable needs, not the entire state object. Use remember and derivedStateOf to avoid unnecessary recomputation. Use key parameters to help Compose identify what changed. Whether you use MVVM or MVI, the Compose-specific optimization is the same: keep your state stable, keep your Composable parameters focused, and let Compose's smart recomposition do its job.

Closing

Here is the honest truth about state management. No pattern eliminates complexity — each one moves it to a different place. MVVM moves it to the coordination between multiple streams. MVI moves it to the boilerplate of intents and reducers. Both work. Both have been used to ship successful, large-scale Android apps. The question is not "which pattern is best?" The question is "which trade-off is most acceptable for my screen, my team, and my timeline?" Understand the trade-offs, make an informed choice, and do not let anyone tell you there is one right answer. Next up, we are diving into modularization — the cost nobody talks about.