Why Sealed Classes Shine for UI State

In Android development, UI often reflects a limited set of mutually exclusive states: loading, success, error, or empty. Modeling these states with plain data classes or nullable fields leads to boilerplate, accidental impossible states, and hard‑to‑read when chains. Kotlin’s sealed classes give us a compile‑time guarantee that all possible states are known, letting the compiler exhaustively check our when expressions. This eliminates a whole class of runtime bugs and makes the intent of the code obvious at a glance.

Real‑World Scenario: Fetching User Profile

Imagine a screen that shows a user’s profile. The view model exposes a StateFlow of UI state that the UI collects. The possible states are:

  • Loading – while the network request is in flight.
  • Success – when we have the user data.
  • Error – when something went wrong.
  • Empty – when the user has no profile data (e.g., newly signed up).

Using a sealed class, each state becomes a distinct subclass, carrying only the data it needs.

Defining the Sealed State Class


sealed interface UiState {
    data object Loading : UiState
    data class Success(val user: User) : UiState
    data class Error(val message: String) : UiState
    data object Empty : UiState
}

The sealed interface (available since Kotlin 1.5) works the same as a sealed class but allows implementations via objects or data classes. Each concrete type carries exactly the data relevant to that state — no more, no less.

Collecting the State in the UI

In a Jetpack Compose screen, we collect the StateFlow and react with a when that the compiler knows is exhaustive.


@Composable
fun UserProfileScreen(viewModel: UserProfileViewModel = hiltViewModel()) {
    val uiState by viewModel.uiState.collectAsState()

    when (uiState) {
        is UiState.Loading -> {
            // Show a progress indicator
            CircularProgressIndicator(modifier = Modifier.fillMaxSize())
        }
        is UiState.Success -> {
            // Display the user data
            ProfileContent(user = uiState.user)
        }
        is UiState.Error -> {
            // Show an error message with a retry action
            ErrorMessage(message = uiState.message, onRetry = viewModel::reload)
        }
        is UiState.Empty -> {
            // Prompt the user to create a profile
            EmptyState(onCreateProfile = viewModel::navigateToCreate)
        }
    }
}

Because UiState is sealed, the compiler will warn us if we forget to handle a new state. Adding a new case forces us to update every when that checks the state, keeping the UI in sync with the data model.

Why This Approach Beats Alternatives

You might be tempted to use a nullable User? plus a boolean isLoading and a String? for error. That representation permits impossible combinations — for example, isLoading == true and user != null — which you then have to guard against with extra if checks. Sealed classes make those illegal states unrepresentable.

Another alternative is to model state as a map or a pair of flags. While flexible, it shifts the burden of correctness to runtime and makes the code harder to read. With sealed classes, the intent is self‑documenting: each branch of the when corresponds to a distinct visual outcome.

Testing the State Logic

Because each state is a plain data class (or object), unit testing the view model becomes straightforward. You can assert the exact type and payload:


@Test
fun `fetchUser returns success state`() = runTest {
    // given
    val repo = mockk()
    every { repo.getUser() } returns User("Alice", "alice@example.com")
    val viewModel = UserProfileViewModel(repo)

    // when
    viewModel.fetchUser()

    // then
    assertTrue(viewModel.uiState.value is UiState.Success)
    val success = viewModel.uiState.value as UiState.Success
    assertEquals("Alice", success.user.name)
}

The test reads like a specification: we expect a Success state containing a particular user.

When to Avoid Sealed Classes

Sealed classes shine when the number of states is small and fixed. If you have dozens of possible states or states that can combine (e.g., loading and showing partial data), consider a different model — perhaps a UI state object with optional fields, or a state‑machine library. But for typical loading‑success‑error‑empty patterns, sealed classes are the idiomatic Kotlin solution.

Takeaway

Use sealed classes to model mutually exclusive UI states. Let the compiler enforce exhaustiveness, eliminate impossible states, and keep your UI code clean and self‑describing.

Next time you reach for a bunch of booleans and nullable fields to drive your UI, pause and ask whether a sealed class would make the design clearer. You’ll likely find that the extra few lines of definition save you many lines of defensive checks later on.