Sealed Classes for UI State Are an Anti-Pattern You Copied From a Conference Talk
Loading, Success, Error. Three words that launched a thousand boilerplate files and made every screen in your app harder to build.
Sealed Classes for UI State Are an Anti-Pattern You Copied From a Conference Talk
Loading, Success, Error. Three words that launched a thousand boilerplate files and made every screen in your app harder to build.

You know the pattern. You’ve written it dozens of times. It probably lives in your core:ui module right now:
sealed class UiState<out T> {
object Loading : UiState<Nothing>()
data class Success<T>(val data: T) : UiState<T>()
data class Error(val message: String) : UiState<Nothing>()
}
Simple. Elegant. Type-safe. The entire Android community adopted it between 2019 and 2021. Every conference talk about MVI or state management featured some version of it. Every sample project from Google used it. Every “Clean Architecture template” on GitHub ships with it.
And it’s been quietly making your UI code worse ever since.
I’m not saying sealed classes are bad. I’m not saying modeling state explicitly is wrong. I’m saying this specific pattern the generic tristate wrapper creates problems that compound across every screen, and there are better ways to model what’s actually happening in your UI.
The Pattern Breaks Down on the Second Requirement
The sealed tristate works beautifully in a tutorial. One screen. One data source. One async call. Loading, then success or error. Done.
Now build a real screen.
Your profile screen loads the user’s info, their recent posts, and their notification count. These three pieces of data come from different endpoints. They load at different speeds. The user info is critical the screen makes no sense without it. The posts are important but can appear after a delay. The notification count is a nice-to-have that shouldn’t block anything.
Model this with the sealed tristate:
// Option A: One state for everything
sealed class ProfileUiState {
object Loading : ProfileUiState()
data class Success(
val user: User,
val posts: List<Post>,
val notificationCount: Int
) : ProfileUiState()
data class Error(val message: String) : ProfileUiState()
}
The screen shows a loading spinner until all three calls complete. The user stares at a blank screen for two seconds waiting for the notification count API that nobody cares about. If the notification endpoint fails, the entire screen shows an error even though user info and posts loaded fine.
“So model them separately.”
// Option B: Three separate states
class ProfileViewModel {
val userState: StateFlow<UiState<User>>
val postsState: StateFlow<UiState<List<Post>>>
val notificationState: StateFlow<UiState<Int>>
}
Now your Compose function has three when expressions. That's nine possible combinations (3 states x 3 states x 3 states), and your UI needs to handle all of them. What do you show when user is Success, posts are Loading, and notifications are Error? What about Loading, Success, Error? What about Error, Loading, Success?
You wanted to model three states. You got 27. Most of them are edge cases you haven’t thought about, handled by whatever the else branch does which is usually nothing, or a full-screen error that wipes out the data that loaded successfully.
This is not a scaling problem you solve with better architecture. It’s a fundamental flaw in treating UI state as a set of independent loading envelopes.
It Conflates Two Different Concepts
The sealed tristate mixes data availability with screen state. These are not the same thing.
Data availability answers: “Has this piece of data been fetched?” Screen state answers: “What should the user see right now?”
A profile screen might need to express:
- “We’re loading everything for the first time” (full skeleton)
- “We have the user but posts are still loading” (show user header, shimmer for posts)
- “Everything loaded, showing it all” (full content)
- “Refreshing showing stale data with a progress indicator” (pull-to-refresh)
- “The posts failed but everything else is fine” (show user, show error banner for posts)
- “The user’s session expired” (show re-auth prompt, keep cached data visible)
None of these map cleanly to Loading | Success | Error. Real screen states are multidimensional. They involve combinations of data presence, staleness, refresh status, and error granularity. Forcing them into a tristate loses information your UI needs to render correctly.
The sealed tristate is a model of an HTTP call, not a model of a screen. And your UI shouldn’t be organized around HTTP calls.
It Destroys Previously Loaded Data
This is the most user-hostile consequence, and it’s baked into the pattern.
User opens the profile screen. Data loads. Success(user). Everything is visible. User pulls to refresh. State transitions to Loading. The Compose function hits the Loading branch and renders a spinner. The user's data disappears. For a refresh that takes 800 milliseconds, the user watches their screen blank out and come back.
“Just handle that case.” How? The Loading state doesn't carry data. You have two options:
Option A: Add data to Loading.
sealed class UiState<out T> {
data class Loading<T>(val previousData: T? = null) : UiState<T>()
data class Success<T>(val data: T) : UiState<T>()
data class Error<T>(val message: String, val previousData: T? = null) : UiState<T>()
}
Now every state optionally carries data. You check for previousData in every branch. The sealed class has lost its simplicity its only selling point and become a nullable soup where you're never sure which fields are populated.
Option B: Track refresh state separately.
data class ProfileUiState(
val user: User?,
val isRefreshing: Boolean,
val error: String?
)
Congratulations. You’ve abandoned the sealed class pattern and moved to a data class. Which is what you should have done from the start.
It Forces Lossy Transitions
State machines transition. Loading → Success → Loading → Error → Loading → Success. Each transition replaces the previous state entirely. There's no memory, no accumulation, no "I have some data and I'm getting more."
Real UIs accumulate state:
- A paginated list loads page 1, then page 2, then page 3. At every step, previous pages remain visible. After page 3, the state is
[page1 + page2 + page3], notSuccess(page3). - A form validates fields as the user types. Some fields are valid, some have errors, some haven’t been touched. The state is a composite, not a single value.
- A dashboard has six cards. Four loaded, one is loading, one errored. The user sees four cards, a shimmer, and an error retry button. The state is per-card, not per-screen.
The sealed tristate makes each of these cases awkward. You either nest UiState inside UiState (a Success containing a list of UiState per item), which is unreadable, or you abandon the pattern for these cases and use it inconsistently across your app.
The when Exhaustiveness Tax
Sealed classes force exhaustive when expressions. This is usually presented as a benefit: "The compiler ensures you handle every case."
In practice, here’s what happens. You have a when block in your Compose function:
when (state) {
is UiState.Loading -> LoadingScreen()
is UiState.Success -> ProfileContent(state.data)
is UiState.Error -> ErrorScreen(state.message)
}
Now you add a fourth state Empty, because the user has no posts. You add it to the sealed class. The compiler flags every when expression that uses UiState across your entire codebase. You visit 15 screens, add an is UiState.Empty -> EmptyScreen() branch to each, and realize that "empty" means different things on different screens. The profile empty state shows "No posts yet." The search empty state shows "No results found." The notifications empty state shows "You're all caught up."
So Empty can't be generic. You need per-screen empty states. But the generic UiState<T> sealed class doesn't support per-screen customization. You either add a message parameter to Empty (defeating the point of type safety) or create per-screen sealed classes which means you've abandoned the generic wrapper and are writing bespoke state classes anyway.
The exhaustive when wasn't protecting you. It was forcing you to handle a state uniformly across screens where the handling is inherently different.
What to Do Instead
Model the screen, not the request
Instead of wrapping your data in a loading envelope, describe what the screen actually looks like:
data class ProfileScreenState(
val user: User? = null,
val posts: List<Post> = emptyList(),
val notificationCount: Int = 0,
val isInitialLoading: Boolean = true,
val isRefreshing: Boolean = false,
val userError: String? = null,
val postsError: String? = null,
)
This state is additive. Data arrives incrementally. The user becomes visible as soon as it loads. Posts appear when they’re ready. Errors are granular a failed posts call doesn’t hide the user. Refreshing overlays on existing data instead of replacing it. Pull-to-refresh sets isRefreshing = true without touching any loaded data.
Your Compose function reads top-to-bottom:
@Composable
fun ProfileScreen(state: ProfileScreenState) {
if (state.isInitialLoading) {
ProfileSkeleton()
return
}
PullToRefresh(isRefreshing = state.isRefreshing) {
state.user?.let { UserHeader(it) }
state.userError?.let { ErrorBanner(it) }
if (state.posts.isNotEmpty()) {
PostsList(state.posts)
}
state.postsError?.let { ErrorBanner(it, retryLabel = "Reload posts") }
}
}
No when branches. No sealed class hierarchy. No gymnastics to preserve data across loading transitions. The state is a snapshot of what the screen looks like, and the Compose function renders it directly.
Use sealed classes for finite, meaningful distinctions
Sealed classes shine when the states are genuinely distinct and finite:
sealed class AuthStatus {
object Authenticated : AuthStatus()
object Anonymous : AuthStatus()
data class Expired(val canRefresh: Boolean) : AuthStatus()
}
This has three states that are meaningfully different, require different UI, and don’t carry optional overlap. There’s no “loading” because auth status is determined synchronously from a token. There’s no “error” because checking a token doesn’t fail. The sealed class models a real domain concept, not a network call lifecycle.
sealed class SubscriptionTier {
object Free : SubscriptionTier()
object Pro : SubscriptionTier()
data class Enterprise(val seats: Int) : SubscriptionTier()
}
Three real business concepts. Not three states of a progress bar.
Let the ViewModel own the complexity
The ViewModel’s job is to absorb the messiness of multiple async sources and produce a single, stable screen state:
class ProfileViewModel(
private val userRepo: UserRepository,
private val postRepo: PostRepository
) : ViewModel() {
private val _state = MutableStateFlow(ProfileScreenState())
val state: StateFlow<ProfileScreenState> = _state.asStateFlow()
init {
loadUser()
loadPosts()
}
private fun loadUser() {
viewModelScope.launch {
userRepo.getUser(userId)
.onSuccess { user ->
_state.update { it.copy(user = user, isInitialLoading = false) }
}
.onFailure { e ->
_state.update { it.copy(userError = e.message, isInitialLoading = false) }
}
}
}
private fun loadPosts() {
viewModelScope.launch {
postRepo.getUserPosts(userId)
.onSuccess { posts ->
_state.update { it.copy(posts = posts) }
}
.onFailure { e ->
_state.update { it.copy(postsError = e.message) }
}
}
}
fun refresh() {
_state.update { it.copy(isRefreshing = true) }
loadUser()
loadPosts()
}
}
Each data source updates the state independently. The UI renders whatever is available. Errors are additive, not destructive. Refresh preserves existing data. The screen progressively fills in rather than jumping between monolithic states.
The Root Cause
The sealed tristate pattern became dominant because it solved the simplest possible case in the most elegant possible way. A conference demo showing Loading → Success → Error with a when expression is compelling. It's concise. It's type-safe. It makes the audience nod.
But conference demos don’t have pull-to-refresh. They don’t have partial loading. They don’t have six data sources per screen. They don’t have an offline mode where you show cached data with a connectivity banner. They don’t have pagination, optimistic updates, or background syncs.
Your app has all of those. And the pattern that looked elegant for “fetch one thing and display it” becomes a straitjacket for everything else.
The lesson isn’t that sealed classes are bad. It’s that the model of your UI state should reflect the complexity of your UI, not the simplicity of your network layer. When those differ and they always differ the generic tristate wrapper forces your UI to pretend it’s simpler than it is. The result is data loss on refresh, all-or-nothing error handling, and loading states that hide perfectly good content.
Model the screen. Not the request.
If you like my work. You can support me by offering a coffee

메타데이터
- post_id
- 51c46e4e4d5a
- slug
- sealed-classes-for-ui-state-are-an-anti-pattern-you-copied-from-a-conference-talk-51c46e4e4d5a
- url
- https://medium.com/@himanshugaur684/sealed-classes-for-ui-state-are-an-anti-pattern-you-copied-from-a-conference-talk-51c46e4e4d5a
- canonical_url
- https://medium.com/@himanshugaur684/sealed-classes-for-ui-state-are-an-anti-pattern-you-copied-from-a-conference-talk-51c46e4e4d5a
- author_url
- https://medium.com/@himanshugaur684
- status
- ok
- fetched_at
- 2026-06-09 15:37:30