androidengineers.Book a session

State Management

Modeling UI State with Sealed Classes

article20 minMedium

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

FeatureEnumSealed Class
Carries data per variantNoYes
Exhaustive whenYesYes
Subclasses in same file onlyYesYes (sealed)
Can have different constructor paramsNoYes
Suitable for simple flagsYesOverkill
Suitable for async UI stateNoYes

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

ConceptGuidance
Sealed class UiStateEliminate impossible state combinations
Compiler exhaustionwhen without else — add a new subclass, the build breaks
Sealed interfaceUse when a type needs to belong to multiple hierarchies
Independent loadingData class with AsyncResult<T> fields per section
Partial load / refreshisRefreshing flag inside Success subclass
Null anti-patternNever use null to mean "loading" or "failed"
Enums vs sealedEnums for labels; sealed classes when variants carry different data

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Modeling UI State with Sealed Classes | Android System Design | Android Engineers