Designers want the list screen to keep showing old data while a refresh runs, and to show a refresh error as a banner without hiding the data. A flat sealed Loading/Success/Error model can't express 'data + refreshing' or 'data + error'. How do you redesign the state?
answer
- Two independent axes -> product of two sealed sums
- Content: Empty|Loaded ; Status: Idle|Refreshing|Failed
- data class combines them (product type)
- Stale-while-revalidate needs data + status together
- Flat sum forces one state; product allows combinations
basics
~20 sSplit the state into two parts: the content (the data you already have) and the loading/refresh status. Then you can show old data and a 'refreshing' or 'error' badge at the same time, instead of forcing the screen into a single Loading or Error box.
solid answer
~40 sThe flat `Loading | Success | Error` model conflates two independent dimensions: *do we have content?* and *what is the async status?* When they must coexist (stale-while-revalidate), make state a `data class` with two fields — `content: Content` and `status: LoadStatus` — where each field is its own small sealed type. `Content` is `Empty | Loaded(items)`; `LoadStatus` is `Idle | Refreshing | Failed(error)`. Now `Loaded(items) + Refreshing` and `Loaded(items) + Failed(e)` are naturally representable, while still keeping each axis exhaustive. This is product-of-sums composition: a sealed type per axis, combined in a data class. Alternatively, enrich the success case itself (`Success(data, isRefreshing, refreshError)`), but that re-introduces optional fields; the two-axis decomposition keeps each axis's illegal states out.
code
kotlin · 13 linessealed interface Content {
data object Empty : Content
data class Loaded(val items: List<Item>) : Content
}
sealed interface LoadStatus {
data object Idle : LoadStatus
data object Refreshing : LoadStatus
data class Failed(val error: String) : LoadStatus
}
data class ListUiState(val content: Content, val status: LoadStatus)
// stale data + visible refresh error, both at once:
val s = ListUiState(Content.Loaded(items), LoadStatus.Failed("network"))go deeper
Recognizes the flat model can't show data and loading together but may not name the fix.
Proposes splitting into content + status fields and keeps each as a small sealed type.
Frames it as product-of-sums, discusses the residual illegal combinations and when the flat model is still right.
Sets a state-modeling convention, balances axis decomposition vs. validation at boundaries, and considers diffing/perf and team comprehension.
## Why the flat model breaks `sealed { Loading; Success(data); Error(e) }` assumes the three states are mutually exclusive. But 'show stale data while refreshing' and 'show data with an error banner' are combinations of two *orthogonal* questions: 1. **Content axis** — do we have something to render? (`Empty` vs `Loaded`) 2. **Status axis** — what is the background operation doing? (`Idle` / `Refreshing` / `Failed`) A single sum type with three cases can express at most one point; you can't sit on both axes at once. ## Decompose into a product of two sums Keep each axis as its own sealed type, then combine them in a `data class` (a **product type** — it holds field A *and* field B): ```kotlin sealed interface Content { data object Empty : Content data class Loaded(val items: List<Item>) : Content } sealed interface LoadStatus { data object Idle : LoadStatus data object Refreshing : LoadStatus data class Failed(val error: String) : LoadStatus } data class ListUiState( val content: Content, val status: LoadStatus, ) ``` Now `ListUiState(Content.Loaded(items), LoadStatus.Refreshing)` and `ListUiState(Content.Loaded(items), LoadStatus.Failed(e))` are first-class, valid states. Each axis is still **exhaustive** in its own `when`: ```kotlin val banner = when (val s = state.status) { LoadStatus.Idle -> null LoadStatus.Refreshing -> "Refreshing…" is LoadStatus.Failed -> s.error } val body = when (val c = state.content) { Content.Empty -> EmptyPlaceholder is Content.Loaded -> ItemList(c.items) } ``` ## Trade-offs and the alternative - A **product of sums** allows *all* cross combinations. If some combination is itself illegal (e.g. `Empty + Failed` should never occur), the product can't forbid it — you regain a few representable-but-illegal states. Choose the decomposition whose impossible combinations you don't actually mind. - The alternative is to keep one sum type but make the success case rich: `data class Success(val items: List<Item>, val refreshing: Boolean, val refreshError: String?)`. This works but smuggles optional/boolean fields back in — exactly the 'illegal states' smell — so prefer it only when the extra status truly belongs to *Success* alone. - For Compose/`StateFlow`, both `data class` state holders give value `equals`, so recomposition/emission diffing is correct. ## Rule of thumb When two pieces of state vary independently, model **two axes** (product), not the cartesian explosion as a flat sum. When they are genuinely mutually exclusive, a single sum type is the cleaner ADT.
- Doesn't the product type reintroduce some illegal states (e.g. Empty + Failed)?Yes — a product allows every combination, so a few illegal pairs become representable. You accept that trade for the combinations you need; if a pair is truly forbidden and harmful, factor it back into a sum type or validate at the boundary.
- When is the flat Loading/Success/Error model still the right call?When the states really are mutually exclusive — e.g. an initial first-load screen that shows a spinner, then content, then an error, with no stale-while-revalidate. Adding axes there is over-engineering.
A dashboard has a fuel gauge AND a speedometer — independent dials. Forcing them into one 'mode' selector would lose information; you keep two dials.
saying these in an interview costs you the question
- Insisting one flat sum type can model independent axes
- Cramming refreshing/error into Success as booleans/nullables without justification
- Not recognizing product-of-sums as the composition tool
- Claiming the product fully eliminates illegal states (it doesn't)
- Ignoring that data class equals matters for StateFlow/Compose diffing