Make MVI your strength, not your weakness.

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>) : PromoCodesScreenState

A 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:

  1. list of promo codes

  2. selected filters

  3. 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>) : PromoCodesScreenState

Or 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

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.

Reply

Avatar

or to participate

Keep Reading