How do you model UI state with sealed classes/interfaces instead of a pile of booleans?
A pile of booleans lets your UI reach impossible states — sealed classes let the compiler decide which ones can exist at all.
LEARN BY DOINGPut your intuition to work3 steps
How do you model UI state with sealed classes/interfaces instead of a pile of booleans?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.Ask ten Android engineers to model a screen that loads data, and a good number will reach for the same four fields: isLoading: Boolean, isError: Boolean, errorMessage: String?, and data: List<Item>?. It compiles, it ships, and it is quietly wrong — those four fields describe 2⁴ = 16 possible combinations, and the screen only has three legitimate states. Nothing in the type system stops isLoading and isError from both being true at once, or data being non-null while isLoading is also true, and every when block or if chain that renders this state now has to defensively guess which combination is real.
#Making illegal states unrepresentable
The fix is not more booleans or more defensive checks — it's a type that can only be constructed in a way that is already valid. A sealed interface (or sealed class) declares a closed, finite set of subtypes, known entirely at compile time, and the Kotlin compiler uses that closure to require an exhaustive when — no else branch, and a compile error the moment a new variant exists somewhere it wasn't handled.
sealed interface CatalogUiState {data object Loading : CatalogUiStatedata class Success(val items: List<Product>,val isRefreshing: Boolean = false,) : CatalogUiStatedata class Error(val message: String) : CatalogUiState}
Every branch here is mutually exclusive by construction: there is no way to hold items and also be Loading, because items only exists on Success. Loading is declared as data object — Kotlin 1.9's improvement over a bare object for singletons, giving it a sensible toString() and value-based equality for free, which matters once this state starts flowing through StateFlow and test assertions.
#Wiring it through a ViewModel
class CatalogViewModel(private val repo: CatalogRepository) : ViewModel() {private val _uiState = MutableStateFlow<CatalogUiState>(CatalogUiState.Loading)val uiState: StateFlow<CatalogUiState> = _uiState.asStateFlow()init { load() }fun load() {viewModelScope.launch {_uiState.value = CatalogUiState.LoadingrunCatching { repo.fetchProducts() }.onSuccess { products -> _uiState.value = CatalogUiState.Success(products) }.onFailure { e -> _uiState.value = CatalogUiState.Error(e.message ?: "Unknown error") }}}fun refresh() {val current = _uiState.valueif (current !is CatalogUiState.Success) returnviewModelScope.launch {_uiState.value = current.copy(isRefreshing = true)val products = repo.fetchProducts()_uiState.value = CatalogUiState.Success(products, isRefreshing = false)}}}
Notice refresh() reads back current as Success before doing anything — pull-to-refresh only makes sense once there's already data on screen, and the type system enforces that by simply not giving you an isRefreshing field anywhere else. That's the boolean-pile bug from the opening paragraph, closed by construction instead of by a runtime guard someone has to remember to write.
#Consuming it exhaustively in Compose
@Composablefun CatalogScreen(viewModel: CatalogViewModel = viewModel()) {val uiState by viewModel.uiState.collectAsStateWithLifecycle()when (val state = uiState) {is CatalogUiState.Loading -> LoadingSpinner()is CatalogUiState.Success -> ProductGrid(items = state.items,isRefreshing = state.isRefreshing,onRefresh = viewModel::refresh,)is CatalogUiState.Error -> ErrorBanner(state.message, onRetry = viewModel::load)}}
The when here has no else and needs none — the compiler already knows those three branches are the only ones that can ever exist. Add a fourth state, say Empty, to the sealed interface next sprint, and every when over CatalogUiState in the codebase turns red until it's handled. With the boolean version, the equivalent change is a silent no-op: a new flag nobody remembered to check anywhere.
#Why this is the answer worth giving
A junior answer to this question is "use a sealed class instead of booleans." A senior answer explains why: booleans model state as an unconstrained product of independent flags, while a sealed hierarchy models it as a constrained sum of mutually exclusive cases — and that difference is exactly the gap between states that compile but shouldn't exist, and states that can't exist at all. It's also what makes refactors safe months later, because the compiler — not a stale comment or a forgotten TODO — is the thing enforcing that every consumer keeps up.