Why Model UI State Explicitly
A screen can be in many mutually exclusive situations: loading data, showing results, showing an error, or empty. Without explicit modeling, teams reach for nullable fields and boolean flags:
// Common but fragile — what does isLoading=true AND error != null mean?
var isLoading: Boolean = false
var data: List<Item>? = null
var error: String? = null
This approach allows impossible states. Sealed classes solve this by making illegal state combinations unrepresentable.
Sealed Class Hierarchy for UiState
A sealed class restricts its subclasses to the same file/package, giving the compiler exhaustive when expressions — you are forced to handle every case.
sealed class NewsUiState {
object Loading : NewsUiState()
data class Success(val articles: List<Article>) : NewsUiState()
data class Error(val message: String, val cause: Throwable? = null) : NewsUiState()
object Empty : NewsUiState()
}
Each subclass carries only the data that is relevant to its state. Loading carries nothing; Success carries the list; Error carries the failure reason.
ViewModel emitting sealed states
@HiltViewModel
class NewsViewModel @Inject constructor(
private val newsRepository: NewsRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<NewsUiState>(NewsUiState.Loading)
val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()
init {
fetchNews()
}
fun fetchNews() {
viewModelScope.launch {
_uiState.value = NewsUiState.Loading
try {
val articles = newsRepository.getTopHeadlines()
_uiState.value = if (articles.isEmpty()) {
NewsUiState.Empty
} else {
NewsUiState.Success(articles)
}
} catch (e: Exception) {
_uiState.value = NewsUiState.Error(
message = e.localizedMessage ?: "Unknown error",
cause = e
)
}
}
}
}
Rendering Sealed State in Compose
@Composable
fun NewsScreen(viewModel: NewsViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when (val state = uiState) {
is NewsUiState.Loading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
is NewsUiState.Success -> {
LazyColumn {
items(state.articles) { article ->
ArticleItem(article = article)
}
}
}
is NewsUiState.Error -> {
ErrorView(
message = state.message,
onRetry = viewModel::fetchNews
)
}
is NewsUiState.Empty -> {
EmptyView(message = "No articles found")
}
}
}
Notice: the compiler enforces that all branches are handled. If you add a new subclass (Refreshing, for example) and forget to handle it in the when, the build fails.
Rendering Sealed State in Fragment/Activity
class NewsFragment : Fragment() {
private val viewModel: NewsViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
when (state) {
is NewsUiState.Loading -> showLoading()
is NewsUiState.Success -> showArticles(state.articles)
is NewsUiState.Error -> showError(state.message)
is NewsUiState.Empty -> showEmpty()
}
}
}
}
}
private fun showLoading() {
binding.progressBar.isVisible = true
binding.recyclerView.isVisible = false
binding.errorView.isVisible = false
binding.emptyView.isVisible = false
}
private fun showArticles(articles: List<Article>) {
binding.progressBar.isVisible = false
binding.recyclerView.isVisible = true
binding.errorView.isVisible = false
binding.emptyView.isVisible = false
adapter.submitList(articles)
}
// ... other show functions
}
Sealed Interface vs Sealed Class
Kotlin 1.5+ introduced sealed interfaces. The key difference: a class can implement multiple sealed interfaces, but can only extend one sealed class.
// Sealed class — traditional approach
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Failure(val exception: Exception) : Result<Nothing>()
object Loading : Result<Nothing>()
}
// Sealed interface — more flexible for composition
sealed interface UiState
sealed interface LoadingState : UiState {
object Loading : LoadingState
object Idle : LoadingState
}
data class ContentState(val items: List<String>) : UiState
data class ErrorState(val message: String) : UiState
// A class can implement multiple sealed interfaces
class RefreshingContent(val items: List<String>) : UiState, LoadingState
For most UiState modeling, a sealed class is simpler and sufficient. Prefer sealed interfaces when you need a type to belong to multiple hierarchies.
Combining Multiple Independent States
Not all screens have a single main loading state. A screen might have a user profile loading independently from their posts. Two approaches exist:
Option 1: Nested sealed classes (when states are truly dependent)
sealed class ProfileUiState {
object Loading : ProfileUiState()
data class Success(
val user: User,
val postsState: PostsState
) : ProfileUiState()
data class Error(val message: String) : ProfileUiState()
}
sealed class PostsState {
object Loading : PostsState()
data class Success(val posts: List<Post>) : PostsState()
data class Error(val message: String) : PostsState()
}
Option 2: Data class with independent nullable fields (when states are independent)
data class DashboardUiState(
val userProfile: AsyncResult<User> = AsyncResult.Loading,
val recentOrders: AsyncResult<List<Order>> = AsyncResult.Loading,
val notifications: AsyncResult<List<Notification>> = AsyncResult.Loading
)
sealed class AsyncResult<out T> {
object Loading : AsyncResult<Nothing>()
data class Success<T>(val data: T) : AsyncResult<T>()
data class Error(val message: String) : AsyncResult<Nothing>()
}
This lets each section of the screen load and fail independently:
@Composable
fun DashboardScreen(viewModel: DashboardViewModel = hiltViewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Column {
when (val profile = state.userProfile) {
is AsyncResult.Loading -> ProfileSkeleton()
is AsyncResult.Success -> ProfileHeader(profile.data)
is AsyncResult.Error -> Text("Profile error: ${profile.message}")
}
when (val orders = state.recentOrders) {
is AsyncResult.Loading -> OrdersSkeleton()
is AsyncResult.Success -> OrdersList(orders.data)
is AsyncResult.Error -> RetryableError("Orders", viewModel::retryOrders)
}
}
}
Partial Loading States (Refresh Pattern)
A common requirement: show existing content while refreshing. A boolean flag attached to Success handles this cleanly:
sealed class FeedUiState {
object Loading : FeedUiState() // initial load, no content yet
data class Success(
val posts: List<Post>,
val isRefreshing: Boolean = false // pull-to-refresh while showing existing data
) : FeedUiState()
data class Error(
val message: String,
val cachedPosts: List<Post> = emptyList() // show stale data with error banner
) : FeedUiState()
}
fun onPullToRefresh() {
val current = _uiState.value
if (current is FeedUiState.Success) {
// Show spinner over existing content, not a blank loading screen
_uiState.value = current.copy(isRefreshing = true)
viewModelScope.launch {
try {
val fresh = repository.getFreshPosts()
_uiState.value = FeedUiState.Success(fresh)
} catch (e: Exception) {
// Revert to success with current data, just stop refreshing
_uiState.value = current.copy(isRefreshing = false)
// Emit a one-time error event via SharedFlow
}
}
}
}
The "null Means Loading" Anti-Pattern
// ANTI-PATTERN — what does null mean?
data class ScreenState(
val user: User? // null because loading? null because not found? null because error?
)
// CORRECT — explicit state
sealed class UserState {
object Loading : UserState()
object NotFound : UserState()
data class Loaded(val user: User) : UserState()
data class Error(val reason: String) : UserState()
}
Null should represent the absence of a concept, not the state of an async operation. Sealed classes make each scenario explicit and force you to handle it.
Sealed Classes vs Enums for States
| Feature | Enum | Sealed Class |
|---|---|---|
| Carries data per variant | No | Yes |
Exhaustive when | Yes | Yes |
| Subclasses in same file only | Yes | Yes (sealed) |
| Can have different constructor params | No | Yes |
| Suitable for simple flags | Yes | Overkill |
| Suitable for async UI state | No | Yes |
Use enums when variants carry no data and are pure labels (sorting order, tab selection). Use sealed classes when each state needs to carry different data.
// Enum is fine here — no data attached
enum class SortOrder { NEWEST_FIRST, OLDEST_FIRST, ALPHABETICAL }
// Sealed class required here — each state has different data
sealed class SearchUiState {
object Idle : SearchUiState()
data class Searching(val query: String) : SearchUiState()
data class Results(val query: String, val hits: List<Product>) : SearchUiState()
data class NoResults(val query: String) : SearchUiState()
data class Error(val query: String, val message: String) : SearchUiState()
}
Key Takeaways
| Concept | Guidance |
|---|---|
| Sealed class UiState | Eliminate impossible state combinations |
| Compiler exhaustion | when without else — add a new subclass, the build breaks |
| Sealed interface | Use when a type needs to belong to multiple hierarchies |
| Independent loading | Data class with AsyncResult<T> fields per section |
| Partial load / refresh | isRefreshing flag inside Success subclass |
| Null anti-pattern | Never use null to mean "loading" or "failed" |
| Enums vs sealed | Enums for labels; sealed classes when variants carry different data |