Brief
The what: A quick snapshot of the context
If you need to refactor your app to add new functionality, it's not because of the wrong architecture. It's because of the wrong Screen State design.
This summer I worked on scaling our payments functionality.
We operated only in the US market and supported a single currency. So the offer object looked like this:
data class OfferState(
val id: String,
val name: String,
val price: Int
)It was rendered as one Text composable in the UI:
Text(text = "Only $${state.price} today!")But suddenly requirements started changing:
we need to start displaying discounts
we need to display how much the user saved
we need to test alternative pricing
we need multi-currency to enter a new market
And so on...
The functionality blew up in a couple of months. And the offer State became:
data class OfferState(
val id: String,
val name: String,
val price: Int,
val discountedPrice: Int?,
val isDiscountedPriceEnabled: Boolean,
val savedAmount: Int?,
val isSavedPriceEnabled: Boolean,
val isDiscountPercentEnabled: Boolean,
val currency: String,
val country: String,
val language: String,
val region: String
)Looking at this, you probably can't even tell what the screen looks like right now.
The first feature took one day to add. The second took two. The third took five. The estimates grew exponentially.
Every new field meant rebuilding the Compose functions, the mappers and utils that read the State, the ViewModel logic and the tests. Code that already worked, rewritten to fit one more use case.
State becomes a slot for every value it could ever hold. Not a snapshot of what the screen holds right now.

🐞 The Issue: Every State change is a refactor of the whole presentation layer.


Based on my recent survey, 9 of 10 devs struggle to integrate AI, I generate 90% of my code while drinking tea and clicking approve on MRs.
How?
I don't invent secret prompts
I don't switch models for each task
I don't spend time refactoring AI code
I created architecture that holds up in production
I created AI infrastructure that builds on it
I automated code review to avoid hallucinations
So I get the code like it was written by a senior engineer with over 10 years of experience, not random garbage.
You can't prompt: "Claude, build this feature". It's not gonna work.
The formula is: architecture AI can't break + documentation AI can read + skills AI can follow.
Then you can finish building the feature while your tea is still hot.
Want this workflow in your codebase?

Breakdown
The when: Reverse engineering the cause and the cost
After working for 10 years as an Android Engineer, I still have debates on every PR about what belongs in the State, what should go into composable logic, and what can become a util function or an extension.
There’s no rule for what the State is, and that’s what makes its design either very simple or very complicated.
New data → New field.
This design style is self-explanatory. When you need to display new things on the screen, you load new data and put it into the State so the UI can consume it.
If we add a discounted price to our offer State, it becomes:
data class OfferState(
val id: String,
val name: String,
val price: Int,
val discountedPrice: Int?
)If we want to display that discount to specific users, we add another field (a segment flag).
data class OfferState(
...
val discountedPrice: Int?,
val isDiscountedPriceEnabled: Boolean
)The UI slowly starts growing conditions as well.
@Composable
fun OfferPrice(state: OfferState) {
Text("Current price $${state.price}")
if (state.isDiscountedPriceEnabled && state.discountedPrice != null) {
Text("One time offer ${state.discountedPrice}")
}
}This type of design looks simple when you have a few fields and becomes extremely complicated as the functionality grows.
If you look at the final State from the “Brief” section, you can’t really say what the screen displays at the moment:
Offer price?
Offer price and discounted price?
Offer price and discounted price with saved amount?
Discount percent?
and so on…
No one really knows until they see what’s rendered on the screen.
This is called a multidirectional State. I explained it in one of my earlier issues.
Or steal my Production-Ready Presentation layer architecture I’ve used for the last 10 years.
New data → New structure.
I’d call the next State design an evolution rather than a separate approach.
If you have a lot of fields and conditions, the most logical step is to group them.
data class OfferState(
val price: Int,
val discount: DiscountState?
)Looking at this State, you can clearly say we have an offer with a price and a discount, if it’s available.
And the UI layer becomes cleaner as well.
@Composable
fun OfferPrice(state: OfferState) {
Text("Current price $${state.price}")
state.discount?.let { Discount(it) }
}But it only looks simpler until you look inside.
data class DiscountState(
val discountedPrice: Int,
val savedAmount: Int,
val isSavedPriceEnabled: Boolean,
val isDiscountPercentEnabled: Boolean
)Now try to explain what’s rendered on the screen just by looking at this State:
only discounted price?
discounted price with saved amount?
or is it a discount percent?
The structure is easier to read, but the main issue stays the same.
New data is never added or removed as a standalone element. It changes the State and the composable in the first design, and the sub-state and child composable in the second. That’s where refactoring starts.
→ Neither of them fixes what belongs in the State. They just change how it looks.

✅ The Fix: Remove computed fields and use multivariant data representation.

Guideline
The how: Implementation guideline and recommendations
State design is a huge topic that can’t be covered in one article, but I’ll show you the exact process I use to identify bad State design and reject my teammates’ PRs in under 30 seconds =)))
Check my book for deep dives on State design and MVI architecture.
Step 1 - Check
There’s one non-negotiable rule I learned after designing a couple of thousand States:
“State describes what is, not what could be.”
When you open the State, it should clearly say what it holds:
✅ Offer price. Offer price with discount. Offer price with saved amount. Offer price with discount percent.
❌ Offer price AND discounted price AND saved amount if enabled OR discount percent.
No conditions. Only one thing at a time.
⚠ NOTE: For large screens with multiple sections, each section should be evaluated separately. For example, product details with customer reviews is a screen with two independent sections.
If the State doesn’t answer that way, the check fails. The next steps fix that.
Step 2 - Remove
This step is about cleaning up computed fields.
A computed field is a field derived from multiple values.
They are often composable logic like isButtonEnabled, or business logic like savedAmount, leaking into the State. They can be any data type.
🧠 PRO TIP: A clear sign that you have one is map(), toUiState() or any other util function in your ViewModel.
In our case, it’s the savedAmount field, which is calculated in the ViewModel by subtracting discountedPrice from price.
It should be moved to the domain layer, because the discount data representation itself is wrong.
Step 3 - Replace
The last types of fields that break State design: conditions and conditional data.
A condition is a boolean that decides what the screen shows. For example: isDiscountedPriceEnabled or isSavedPriceEnabled.
⚠ NOTE: If a condition is a user property or a setting, it should stay as it is. But if this isn’t a settings or onboarding screen, it most likely should be removed.
Conditional data is a field that only makes sense when a condition is true. discountedPrice means nothing while the discount is off.
They always come in groups. And nothing stops them from contradicting each other:
isDiscountedPriceEnabled = true
discountedPrice = null
The discount is on. There's no discounted price. The State allows a screen that can't exist.
🧠 PRO TIP: If another field stops making sense after you remove the boolean condition, they belong to the same group.
⚠ NOTE: Nullability is not always a marker of conditional data. The boolean condition is. Depending on your API design, it can be nullable or not.
This type of data should be represented as multivariant data. That’s where Kotlin’s sealed interface rocks.
Final design
As you might guess, our OfferState doesn’t pass the check.
If we apply all the rules to it, the design becomes:
sealed interface Discount {
object None : Discount
data class Price(val newPrice: String) : Discount
data class SavedAmount(val newPrice: String, val savedAmount: String) : Discount
data class Percent(val newPrice: String, val discountPercent: Int) : Discount
}
data class OfferState(
val id: String,
val name: String,
val price: String,
val discount: Discount
)Also note that prices became a String and the locale data is gone. The domain layer or the backend should be responsible for formatting prices according to regional rules (don’t mix it up with configuration changes).

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 a refactor, and AI builds on it correctly.