
While 49% of devs hate MVVM and 49% hate MVI,
there are 2% who leverage it:
They understand the reasoning behind each line of code
They build architecture that survives any change without a refactor
They automate 90%+ of their work with AI
They constantly land promotions
After 8 years as an internal system, Production-Ready MVI opens.
3 limited founding spots left

Brief
The what: A quick snapshot of the context
Tuesday evening. I receive an MR for code review:
"New promo codes screen"
Checked the attached Figma design. A standard screen with a list of promo codes and loading, success, empty and error states.
The State in the code was what you'd expect:
sealed interface PromoCodesScreenState {
data object Loading : PromoCodesScreenState
data class Error(val message: String) : PromoCodesScreenState
data object Empty : PromoCodesScreenState
data class Success(val promoCodes: List<PromoCode>) : PromoCodesScreenState
}A reasonable design at first glance.
I asked the developer: "Why this State design?"
Got a default answer: "'when' in Compose represents the State better."
I knew where it was going, but decided not to block the MR. For educational purposes 😁.
Wednesday. New MR.
"Added pull to refresh"
Checked the code. A new item in the State hierarchy appeared:
data class Refreshing(val promoCodes: List<PromoCode>) : PromoCodesScreenStateA logical next step: it avoids the full-screen progress, preserves the list of promo codes, and displays the pull to refresh.
I asked the same question: "Why this State design?"
The dev answered: "Either I add isRefreshing to every class, or create one new class with the data I need. The second looked clearer."
So I approved. To see where it ends.
A few MRs later.
More functionality was added:
filter button with selected count
pagination support
A/B test for empty pages
So the final State looked like this:
sealed class PromoCodesScreenState(
open val filtersCount: Int
) {
data class Loading(
override val filtersCount: Int
) : PromoCodesScreenState(filtersCount)
data class Error(
override val filtersCount: Int,
val message: String
) : PromoCodesScreenState(filtersCount)
data class EmptyA(
override val filtersCount: Int
) : PromoCodesScreenState(filtersCount)
data class EmptyB(
override val filtersCount: Int
) : PromoCodesScreenState(filtersCount)
data class Success(
override val filtersCount: Int,
val isRefreshing: Boolean,
val promoCodes: List<PromoCode>,
val cursor: String?,
val paginationState: PaginationState
) : PromoCodesScreenState(filtersCount)
}
sealed interface PaginationState {
object Idle : PaginationState
object Loading : PaginationState
object Error : PaginationState
}And every time you make a small change, the State design complexity explodes.

🐞 The Issue: Every small change requires State redesign.

Breakdown
The when: Reverse engineering the cause and the cost
Before you even say: "Just use a data class".
Think about how you make these decisions:
Single request:
→ use sealed class
Few independent requests:
→ use data class
Multiple requests with mixed mutual state:
→ use data class + sealed class
or maybe:
I don't like data class
I don't like sealed class
I like to have "when" in Compose
Data class can have "nonsense" State
Do these look like the issues you should make architecture decisions on?
Not at all. It's just syntax.
So let's find out the reasoning behind the root State design.
ViewModel and UI relationship
Discussing State design doesn't make sense if you don't understand the relationship between UI and business logic.
In 2014 the first Android apps were built on MVP architecture, where the Presenter was responsible for preparing the exact data the UI needed and sending it to them directly.
But that approach had a lot of issues. Memory leaks and crashes. And that's where MVVM came to the rescue.
MVVM inverted the dependencies between UI and business logic, and the ViewModel became the piece that communicates between them.
→ It means the VM just responds to user actions and doesn't know anything about who's collecting that data. The UI decides where to collect data from.
As a result, responsibilities changed.
The ViewModel just provides a data snapshot, and tells us what it knows:
the promo codes are loading or failed
here are the available promo codes
we have [n] selected filters
an A/B test is enabled
From the other side, the UI decides how to interpret that data snapshot:
display full screen loading on the initial data request
don't show full screen progress if data is refreshing
display an empty placeholder if no promo codes are available
show a notification badge if we have selected filters
→ The ViewModel reports what it knows. The UI decides what that looks like.
UiState ≠ State
Despite the MVP architecture being gone, its main "bad" habit is still there: preparing the exact thing the UI needs, inside business logic.
And there are two consequences of this decision.
1) UI decisions leak into business logic
And it doesn't matter what State holder class you have.
A sealed class:
sealed interface PromoCodesScreenState {
data object Empty : PromoCodesScreenState
}or a data class:
data class PromoCodesScreenState(val isEmpty: Boolean)Syntax is not the problem here. The UI related field is.
Any of them moves the UI's responsibility to make decisions into the business logic.
2) Mappers exist to undo the leak
The transformation has to happen somewhere. You don't want it polluting ViewModel methods, so you move it out into a mapper class or a toUiState() extension.
That's not a fix. That's hiding the leak in a different file.
The mapping still runs on the business logic side. The ViewModel still returns layout decisions. Moving the code out of the class doesn't move the responsibility with it.
There's a part of the Clean Architecture issue here too, because you may pull more data than you need. But that's another issue to discuss.
The ViewModel knows not only that the UI exists, but how it actually renders. Which breaks dependency inversion.
→ The UI is a projection of State. Not the opposite.
Multidirectional State
If you display more than one thing in your UI - it's multidirectional.
In our example we have three directions:
list of promo codes
selected filters
pull to refresh
A direction is a data source with its own lifecycle, represented as a section or an independent element in the UI.
Each direction translates into combinations of UI state, for example:
promo codes → loading, error, empty, success
pull to refresh → refreshing, idle
selected filters → badge with count, no badge
The most critical thing here is that directions don't add. They multiply.
4 x 2 x 2 = 16 combinations
add pagination
4 x 2 x 2 x 3 = 48 combinations
So to make sealed class multidirectional, you either create a combined state:
data class Refreshing(val promoCodes: List<PromoCode>) : PromoCodesScreenStateOr copy data to other states:
data class Success(
override val filtersCount: Int,
val promoCodes: List<PromoCode>
) : PromoCodesScreenState(filtersCount)Because a sealed class tells us that we can have only one direction at a time: loading OR error OR success OR success with selected filters.
But what a State actually is: 12 promo codes WITH 2 selected filters.
→ Sealed types are for one of. A State is all of.
Compiler forced design
Now put the pieces together.
The habit of keeping UI logic inside business logic came from MVP. It never left.
Then Kotlin shipped sealed classes, and devs saw an upgrade:
"Now I can tell my UI what to render through the type system. No nullable fields. No default values in my State."
Two things they miss.
Default values were never a problem.
Any screen has a default representation. Loading as a default. Empty as a default. It's still a representation of that screen.
Multiple fields with defaults or a nullable field - that's not an issue at all. That's what a data looks like.
Redesigns are.
Companies redesign their apps every 3 to 5 years. Fresh look, rebranding. I went through this at 4 companies. It happens more often than you think.
The app stays. The business logic doesn't change. So the ViewModel and its tests shouldn't change either.
But when your State tells the UI what to render, the redesign doesn't stop at composables:
the State structure gets refactored
the logic inside the ViewModel gets refactored
the mappers get rebuilt
the ViewModel tests get rewritten
You didn't swap one composable for a fresher one. You refactored business logic to change a look.
And all of it traces back to a syntax preference. Ask the dev why sealed, and you get: "I like having one when statement." Or: "Smart casting saves me the nullable checks."
That syntax preference costs a full refactoring cycle every time the UI changes for any reason.
→ You saved a null check. It cost you the redesign.
You're not using MVI
The core problem MVI solves: asynchronous work processing with synchronous State updates.
In order to process the queue of Intents and merge their results into a State, you need two things: a single State that represents the data, and a Partial State that represents an output result, so later the Reducer can merge everything together.
→ Because of the singledirectional nature of sealed classes, it's impossible to use them in MVI by default.
Check this out, if you want to master MVI.

✅ The Fix: Use data class for State representation. Don’t save UI logic

Guideline
The how: Implementation guideline and recommendations
"Just use a data class" - it's only 1/3 of the answer. We also need to kick out UI data from the State and prepare properties for better access in Compose.
Let's start building, field by field.
Step 1 - Promo codes request
We need to change the promo codes request representation to pure data and drop the UI decisions.
data class PromoCodesScreenState(
val isLoading: Boolean = true,
val error: String? = null,
val promoCodes: List<PromoCode> = emptyList()
)As you may notice, isLoading = true represents the same initial loading as PromoCodesScreenState.Loading.
But how will the UI know not to display the empty screen, since we set promo codes to empty by default?
The UI decides what to render based on the whole State snapshot, not a single field.
For this purpose I also like to have additional State properties that make the UI logic simpler.
For the empty state decision, we'll have a property like this:
data class PromoCodesScreenState(
val isLoading: Boolean = true,
val error: String? = null,
val promoCodes: List<PromoCode> = emptyList()
) {
val isEmptyStateVisible: Boolean
get() = !isLoading && error == null && promoCodes.isEmpty()
}You might think it's a part of the State. It isn't.
This is just a data access property. If you remove it, it doesn't change the State itself, it doesn't force the ViewModel logic to rebuild, and it doesn't force you to refactor ViewModel tests.
Also, if you don't have UI component tests, you can write separate unit tests to verify them.
Step 2 - Pull to refresh
The new refreshing State direction just becomes a field, not a separate combination like in a sealed class.
data class PromoCodesScreenState(
val isRefreshing: Boolean = false,
...
)And the UI will decide what type of progress to display, not the ViewModel.
Step 3 - Selected filters badge
The filters badge is the third direction of our State. Whatever is going on on the screen, it has its own lifecycle and is always visible.
We don't need to spread its value across the whole hierarchy like with a sealed class, just include it as a new field.
data class PromoCodesScreenState(
val filtersCount: Int = 0,
...
)Step 4 - Pagination
Paging implementation is a bit complicated because it's tied to the lib or custom solution that you have.
In our case we have a custom implementation. The UI decides when to trigger loading based on scroll position, so the only thing we need is to add a cursor that points to the next data snapshot.
So the final State will look like this:
data class PromoCodesScreenState(
val isLoading: Boolean = true,
val isRefreshing: Boolean = false,
val error: String? = null,
val promoCodes: List<PromoCode> = emptyList(),
val filtersCount: Int = 0,
val cursor: String? = null
)As you see, we don't need a separate PaginationState anymore, because the UI can make decisions based on the existing promo codes request data.
We can add properties here as well:
data class PromoCodesScreenState(...) {
val isLoadingFooterVisible: Boolean
get() = isLoading && cursor != null
val isErrorFooterVisible: Boolean
get() = !isLoading && cursor != null && error != null
}Does that mean sealed classes are forbidden in State?
As the root State design - yes.
But there's one case where you still need them: multivariant data representations.
Let's assume there's an A/B test to figure out how to display a discount for a higher conversion rate: as an amount or a percentage.
By default you'd have something like this:
data class PromoCode(
val discountPercent: Int,
val discountAmount: String,
val isABtestEnabled: Boolean
)It's not a rendering decision, it's a business decision, so we shouldn't provide this data to the UI.
And in this case we can represent it using a sealed hierarchy, so the UI just renders it.
sealed interface Discount {
data class Percent(val percent: Int) : Discount
data class Amount(val amount: String) : Discount
}
data class PromoCode(
val discount: Discount
)
Whenever you're ready, here’s how I can help you:
The exact system I used to build 11 apps, with 800+ screens, for 34M users. 10 years of production decisions and the reasoning behind every line, so your architecture survives any change without refactor, and AI builds on it correctly.