03 December 2024
A few months ago, I found a bug in production that took me embarrassingly long to track down. Users were seeing a blank screen â no loading spinner, no error message, nothing. Just white. The logs told the story: isLoading = true and isError = true at the same time. The loading spinner was hidden because the error UI took priority, but the error message was suppressed because the loading state skipped it. Both flags were true, the UI rendered neither, and the user stared at nothing.
The fix was a one-liner â reset isLoading to false before setting isError to true. But the real problem wasnât the bug. The real problem was that the code allowed that state to exist in the first place. Four booleans sat at the top of my ViewModel: isLoading, isError, isEmpty, isRetrying. Four booleans meant 2â´ = 16 possible combinations. I counted: exactly 4 of those 16 were valid states. The other 12 were bugs waiting to happen, and Iâd just found one.
Hereâs the thing â booleans feel like the simplest possible state representation. isLoading is either true or false. What could go wrong? Everything, once you add a second boolean.
Two booleans give you 4 combinations. Three give you 8. Four give you 16. Five give you 32. Think about that â a real ViewModel for a checkout screen might track isLoading, isError, isPaymentProcessing, isAddressValidated, isCartEmpty. Thatâs 5 booleans and 32 possible combinations. In practice, maybe 6 of those 32 represent actual valid states your app can be in. The other 26 are impossible states that your type system happily allows. Can a cart be empty AND payment processing? Can an address be validated AND the screen be in an error state showing âaddress invalidâ? Your business logic says no, but your compiler says sure, go ahead.
Iâve seen this pattern in virtually every Android codebase Iâve worked on. The ViewModel starts clean â one boolean for loading. Then someone adds error handling. Then empty state. Then retry logic. Each addition feels small and harmless, but the combinatorial space grows exponentially while the number of valid states stays roughly constant. By the time you have 4 booleans, 75% of your state space is invalid. By 5 booleans, over 80% is invalid. And in a large codebase with multiple developers, every single state transition is a manual synchronization exercise where forgetting one reset line introduces a bug.
The real cost isnât even the bugs you ship â itâs the mental overhead. Every developer who touches that ViewModel has to hold the entire boolean interaction matrix in their head. Which flags need resetting when? Whatâs the correct ordering? These are questions the type system should answer, not your teamâs tribal knowledge.
The solution is making invalid states unrepresentable. Instead of four booleans that can combine freely, you define a sealed class where each subclass represents exactly one valid state:
sealed interface UiState<out T> {
data object Loading : UiState<Nothing>
data class Success<T>(val data: T) : UiState<T>
data class Error(val message: String) : UiState<Nothing>
data object Empty : UiState<Nothing>
data class Retrying(val previousError: String) : UiState<Nothing>
}
Now the ViewModel holds a single UiState value instead of four booleans. The states are mutually exclusive by construction â you canât be loading AND in an error state because Loading and Error are different types. The compiler enforces this at compile time, not at runtime, and not through code review comments that get ignored.
Notice that Retrying carries a previousError string and Error carries a message. This is one of the biggest advantages sealed classes have over booleans â each state can carry exactly the data relevant to it. With booleans, you end up with a bunch of extra fields like errorMessage, retryCount, lastSuccessData that are only meaningful when certain booleans are true. Youâre always wondering âis errorMessage valid right now or is it stale from a previous error?â With sealed classes, the data lives inside the state that uses it. When youâre in Success, the data is right there. When youâre in Error, the message is right there. No stale fields, no ambiguity.
The boolean version looks like this:
class SearchViewModel(
private val repository: SearchRepository
) : ViewModel() {
private val _isLoading = MutableStateFlow(false)
private val _isError = MutableStateFlow(false)
private val _isEmpty = MutableStateFlow(false)
private val _errorMessage = MutableStateFlow("")
private val _results = MutableStateFlow<List<SearchResult>>(emptyList())
fun search(query: String) {
viewModelScope.launch {
_isLoading.value = true
_isError.value = false // easy to forget this line
_isEmpty.value = false // and this one
try {
val results = repository.search(query)
_results.value = results
_isEmpty.value = results.isEmpty()
} catch (e: Exception) {
_isError.value = true
_errorMessage.value = e.message ?: "Unknown error"
} finally {
_isLoading.value = false
}
}
}
}
And the sealed class version:
class SearchViewModel(
private val repository: SearchRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<UiState<List<SearchResult>>>(UiState.Loading)
val uiState: StateFlow<UiState<List<SearchResult>>> = _uiState.asStateFlow()
fun search(query: String) {
viewModelScope.launch {
_uiState.value = UiState.Loading
_uiState.value = try {
val results = repository.search(query)
if (results.isEmpty()) UiState.Empty
else UiState.Success(results)
} catch (e: Exception) {
UiState.Error(e.message ?: "Unknown error")
}
}
}
}
The boolean version has 5 mutable state fields that need to be kept in sync manually. Every state transition requires resetting the right combination of flags â miss one and you get the blank screen I found in production. The sealed class version has 1 state field, and every transition is a single assignment. Thereâs no âforgetting to resetâ because thereâs nothing to reset. Youâre replacing the state entirely.
Not every state problem needs a sealed class. If your states are a simple, finite set with no data attached to any of them, a Kotlin enum works perfectly and is less ceremony.
Think about a media player. The player is either playing, paused, or stopped. No state carries extra data. No state needs generics. An enum handles this cleanly:
enum class PlayerState {
PLAYING,
PAUSED,
STOPPED
}
Enums also support properties and methods, which makes them surprisingly powerful for simple finite states. A network connection monitor is a good example â each connection type might carry a display label or icon resource:
enum class ConnectionState(val displayName: String) {
CONNECTED("Online"),
DISCONNECTED("Offline"),
CONNECTING("Connecting...");
val isUsable: Boolean get() = this == CONNECTED
}
So when do you reach for a sealed class instead? The moment any state needs to carry unique data. If your error state needs an error message but your loading state doesnât, enum canât express that â every enum constant has the same shape. If your success state carries a list of items but your empty state carries nothing, you need a sealed class. IMO, the rule is simple: enum for uniform states, sealed class for states with different data shapes.
Real apps often have states that themselves contain substates. A payment flow is a perfect example â once youâre in the âprocessingâ phase, there are multiple steps happening underneath.
sealed interface PaymentState {
data object Idle : PaymentState
data class EnteringDetails(
val cardNumber: String = "",
val isCardValid: Boolean = false
) : PaymentState
sealed interface Processing : PaymentState {
data object ValidatingCard : Processing
data object ChargingPayment : Processing
data class AwaitingConfirmation(val transactionId: String) : Processing
}
data class Completed(val receiptUrl: String) : PaymentState
data class Failed(val error: String, val canRetry: Boolean) : PaymentState
}
The nested Processing sealed interface gives you a two-level state machine. At the top level, you know youâre processing. At the inner level, you know exactly which processing step youâre in. Your UI can match on just PaymentState when it only cares about the broad strokes (show a spinner for any Processing subtype), or it can match on the specific Processing variant when it needs to show a progress indicator like âValidating cardâŚâ vs âCharging paymentâŚâ. This pattern maps directly to how real payment SDKs work â Stripeâs PaymentSheet has similar internal state transitions.
Hereâs something sealed classes alone donât give you: transition validation. A sealed class prevents invalid states, but it doesnât prevent invalid transitions between states. Nothing stops you from going directly from Loading to Retrying without passing through Error first, even though that might not make sense in your domain.
For simple flows, you can enforce transitions manually with a function that takes the current state and returns the next valid state. But once your state machine grows beyond 5-6 states with complex transition rules, you want a proper state machine library. Tinderâs StateMachine is one of the more popular ones in the Android ecosystem. It lets you define states, events, and valid transitions declaratively:
val permissionStateMachine = StateMachine.create<PermissionState, PermissionEvent, SideEffect> {
initialState(PermissionState.NotRequested)
state<PermissionState.NotRequested> {
on<PermissionEvent.RequestPermission> {
transitionTo(PermissionState.Requesting, SideEffect.ShowSystemDialog)
}
}
state<PermissionState.Requesting> {
on<PermissionEvent.Granted> {
transitionTo(PermissionState.Granted, SideEffect.EnableFeature)
}
on<PermissionEvent.Denied> {
transitionTo(PermissionState.Denied(showRationale = true))
}
}
state<PermissionState.Denied> {
on<PermissionEvent.RequestPermission> {
transitionTo(PermissionState.Requesting, SideEffect.ShowSystemDialog)
}
on<PermissionEvent.PermanentlyDenied> {
transitionTo(PermissionState.PermanentlyDenied, SideEffect.ShowSettingsPrompt)
}
}
}
Now if something tries to transition from NotRequested directly to Granted, the state machine throws. Youâve encoded not just âwhat states existâ but âhow youâre allowed to move between them.â Iâd say you probably donât need a full state machine library for a typical loading/error/success screen. But for multi-step flows like onboarding, payment processing, or permission requests with rationale dialogs â a formal state machine saves you from a whole class of bugs.
To me, the real test of a pattern is how many different problems it solves cleanly. Here are a few state models Iâve used in production.
Form validation is one of those things that starts as a couple of booleans (isEmailValid, isPasswordValid) and quickly spirals. A sealed class per field keeps it sane, and each state carries the validation context:
sealed interface FieldValidation {
data object Idle : FieldValidation
data object Validating : FieldValidation
data object Valid : FieldValidation
data class Invalid(val errorMessage: String) : FieldValidation
}
data class SignUpFormState(
val email: FieldValidation = FieldValidation.Idle,
val password: FieldValidation = FieldValidation.Idle,
val username: FieldValidation = FieldValidation.Idle
) {
val canSubmit: Boolean
get() = email is FieldValidation.Valid
&& password is FieldValidation.Valid
&& username is FieldValidation.Valid
}
Notice canSubmit is a derived property, not another boolean to sync. Thatâs a pattern worth stealing â compute derived state, donât store it.
sealed interface NetworkState {
data object Available : NetworkState
data class Losing(val remainingMs: Long) : NetworkState
data object Lost : NetworkState
data class Unavailable(val reason: String) : NetworkState
}
This maps directly to Androidâs ConnectivityManager.NetworkCallback â onAvailable, onLosing, onLost, onUnavailable. The Losing state carrying remainingMs lets you show a âconnection unstableâ banner with a countdown, which is something youâd need a separate field for with booleans.
Sealed classes and Jetpack Compose are a natural fit. The exhaustive when expression maps directly to composable rendering, and Composeâs smart recomposition works beautifully with sealed class state because each branch is a distinct type.
@Composable
fun SearchScreen(viewModel: SearchViewModel = viewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
when (state) {
is UiState.Loading -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
is UiState.Success -> {
val results = (state as UiState.Success<List<SearchResult>>).data
LazyColumn {
items(results) { result -> SearchResultItem(result) }
}
}
is UiState.Error -> {
val message = (state as UiState.Error).message
ErrorScreen(message = message, onRetry = { viewModel.search("") })
}
is UiState.Empty -> EmptySearchView()
is UiState.Retrying -> RetryingIndicator()
}
}
If you add a new state â say UiState.PartialResults â the compiler immediately flags every when expression that doesnât handle it. With booleans, adding a new state means adding a new boolean and then hunting through the entire codebase for every if (isLoading) block that might need updating. Youâll miss some. The sealed class approach turns a runtime bug hunt into a compile-time checklist.
Thereâs a Compose-specific win here too. When your state changes from Loading to Success, Compose knows the entire branch has changed, so it recomposes the Success branch from scratch rather than trying to diff against the loading spinner. This is actually more efficient than having a single composable that reads multiple boolean StateFlows and recomposes partially on each flag change. Fewer, coarser recompositions are generally better than many fine-grained ones.
The examples above cover the obvious case â several booleans representing a state machine. But Iâd go further: even two related booleans should make you pause.
Consider isEnabled and isVisible on a button. Four combinations exist: enabled+visible, enabled+invisible, disabled+visible, disabled+invisible. Does âenabled but invisibleâ actually mean anything in your UI? If a button canât be seen, does its enabled state matter? In most cases, these two booleans arenât independent â they represent a single concept like âbutton availabilityâ that has three meaningful states: shown and active, shown but disabled, or hidden entirely.
sealed interface ButtonState {
data object Active : ButtonState
data class Disabled(val reason: String) : ButtonState
data object Hidden : ButtonState
}
Now âenabled but invisibleâ literally cannot exist. And the Disabled state carries a reason, which youâd need a separate string field for in the boolean approach. The sealed class is more expressive AND more constrained â which is exactly what you want from your type system.
The mental model I use is simple: if two booleans are related â meaning changing one might require changing the other â theyâre probably not independent booleans. Theyâre a state machine in disguise. Pull out the valid combinations, give them names, and use a sealed class or an enum.
Iâm not arguing that you should never use booleans. Some states genuinely have exactly two possibilities with no interaction between them. isDarkMode is either true or false, and it doesnât combine with any other state to create invalid combinations. isMuted is a simple toggle. isExpanded for a collapsible section works fine as a boolean.
The test is straightforward: can this boolean combine with other state in a way that creates an invalid combination? If the answer is no â if itâs truly independent â use a boolean. Theyâre simple, theyâre cheap, and everyone understands them. The moment you notice two or more booleans that share a conceptual relationship, thatâs your signal to refactor. Donât wait until you find the production bug. I did, and I donât recommend it.
IMO, the cost of over-engineering with a sealed class for a genuine toggle is real â more code, more ceremony. But the cost of under-engineering with booleans for a state machine is much worse â invalid states, inconsistent UI, and bugs that hide behind the combinatorial complexity you created by accident.
Hereâs what I didnât understand early in my career: the shape of your state is an architectural decision, not just a data modeling convenience. When you choose booleans, youâre choosing to trust developers to maintain invariants manually. When you choose sealed classes, youâre encoding those invariants into the type system and letting the compiler maintain them for you.
This is the same principle behind Kotlinâs null safety. Before Kotlin, Java developers used @Nullable annotations and code review to prevent null pointer exceptions. Kotlin made null a type-level concern. The result wasnât just fewer NPEs â it fundamentally changed how developers thought about optional values. Sealed classes do the same thing for state. They move the invariant enforcement from âdeveloper disciplineâ to âcompiler guarantee.â
I now treat boolean proliferation as a code smell. When I see a PR that adds a third boolean to a ViewModel, I ask: âWhat states are valid here?â And almost every time, the answer leads to a sealed class thatâs cleaner, safer, and easier to reason about than the boolean soup it replaces.
Thank You!