Skip to content
All posts
August 22, 20268 min read

Kotlin Sealed Classes and Interfaces: Modeling UI State Cleanly

Learn how to replace messy enum-and-null-check patterns with sealed class hierarchies that make UI state exhaustive, testable, and impossible to represent illegally.

AndroidKotlinArchitectureBest Practices
Share:

I used to model UI state with enums and nullable fields. Every screen had a

code
status
enum —
code
LOADING
,
code
SUCCESS
,
code
ERROR
— plus a handful of nullable data fields that were only valid in certain states. The compiler couldn't stop me from accessing
code
userData
while
code
status == LOADING
. Bugs slipped through. Sealed classes changed that.

The Problem With Enum-Based State

Enums feel natural at first. You define

code
Loading
,
code
Success
,
code
Error
and switch on them. But real UI state carries data. A success state holds a user profile. An error state holds a message and a retry action. A loading state might hold progress. Enums can't hold data per variant, so you end up with a flat class full of nullable fields:

kotlin
// The old way — fragile and verbose
data class ProfileUiState(
    val status: ProfileStatus = ProfileStatus.Loading,
    val user: User? = null,
    val errorMessage: String? = null,
    val retryAction: (() -> Unit)? = null,
    val progress: Float? = null
)

enum class ProfileStatus { Loading, Success, Error }

This compiles fine. It also lets you construct

code
ProfileUiState(status = Success, user = null)
— an illegal state the compiler accepts. Every consumer must defensively check nulls. Tests need to verify invalid combinations don't crash. The cognitive load grows with every screen.

[!WARNING] Flat state classes with nullable fields create implicit invariants the compiler cannot enforce. Every read site becomes a potential crash site.

Sealed Classes Make Illegal States Unrepresentable

A sealed class hierarchy encodes each valid state as a distinct type. The compiler knows every possible variant. When you handle all branches in a

code
when
expression, you get exhaustiveness checking for free.

kotlin
sealed interface ProfileUiState {
    data class Loading(val progress: Float = 0f) : ProfileUiState
    data class Success(val user: User) : ProfileUiState
    data class Error(val message: String, val retryAction: () -> Unit) : ProfileUiState
}

Now

code
ProfileUiState.Success
requires a
code
User
.
code
ProfileUiState.Error
requires a message and a retry action. You cannot construct
code
Success
without a user. The type system guarantees validity.

kotlin
@Composable
fun ProfileScreen(state: ProfileUiState) {
    when (state) {
        is ProfileUiState.Loading -> LoadingIndicator(progress = state.progress)
        is ProfileUiState.Success -> UserProfileCard(user = state.user)
        is ProfileUiState.Error -> ErrorBanner(
            message = state.message,
            onRetry = state.retryAction
        )
    }
}

No

code
else
branch needed. Add a new state variant and the compiler flags every
code
when
that doesn't handle it.

[!TIP] Use

code
sealed interface
instead of
code
sealed class
when you want to allow multiple inheritance or mix in other interfaces later. It's the modern default since Kotlin 1.5.

Modeling Complex Flows With Nested Sealed Hierarchies

Real screens have sub-flows. A profile screen might show a "edit mode" with its own loading, saving, and validation states. Nest sealed hierarchies to keep each concern isolated.

kotlin
sealed interface ProfileUiState {
    sealed interface ViewMode : ProfileUiState {
        data class ReadOnly(val user: User) : ViewMode
        sealed interface Editing : ViewMode {
            data class InProgress(val draft: UserDraft) : Editing
            data class Saving(val draft: UserDraft) : Editing
            data class ValidationError(val draft: UserDraft, val errors: Map<String, String>) : Editing
            data class Saved(val user: User) : Editing
        }
    }
    data class Loading(val progress: Float = 0f) : ProfileUiState
    data class Error(val message: String, val retryAction: () -> Unit) : ProfileUiState
}

The

code
ViewMode
sealed interface groups read-only and editing states.
code
Editing
further breaks down into draft, saving, validation error, and saved. Each variant carries exactly the data it needs.

kotlin
@Composable
fun ProfileScreen(state: ProfileUiState) {
    when (state) {
        is ProfileUiState.Loading -> LoadingIndicator(progress = state.progress)
        is ProfileUiState.Error -> ErrorBanner(message = state.message, onRetry = state.retryAction)
        is ProfileUiState.ViewMode -> when (state) {
            is ProfileUiState.ViewMode.ReadOnly -> ReadOnlyProfile(user = state.user)
            is ProfileUiState.ViewMode.Editing -> EditingProfile(state = state)
        }
    }
}

@Composable
fun EditingProfile(state: ProfileUiState.ViewMode.Editing) {
    when (state) {
        is ProfileUiState.ViewMode.Editing.InProgress -> EditForm(draft = state.draft)
        is ProfileUiState.ViewMode.Editing.Saving -> EditForm(draft = state.draft, saving = true)
        is ProfileUiState.ViewMode.Editing.ValidationError -> EditForm(
            draft = state.draft,
            errors = state.errors
        )
        is ProfileUiState.ViewMode.Editing.Saved -> SavedConfirmation(user = state.user)
    }
}

The nested

code
when
stays clean. Each level handles its own variants. Adding a new editing sub-state only touches
code
EditingProfile
.

ApproachIllegal States PossibleExhaustiveness CheckingData Per VariantNesting Support
Enum + nullable fieldsYesNoNo (shared fields)Awkward
Sealed class (single level)NoYesYesManual
Nested sealed interfacesNoYesYesNative

Testing Becomes Trivial

Because each state variant is a distinct type with required data, tests construct exactly what they need. No null-check noise. No invalid combinations.

kotlin
class ProfileViewModelTest {

    @Test
    fun `loadUser emits Loading then Success`() = runTest {
        val viewModel = ProfileViewModel(fakeRepository)
        val states = mutableListOf<ProfileUiState>()

        viewModel.uiState.collect { states.add(it) }

        advanceUntilIdle()

        assertEquals(2, states.size)
        assertTrue(states[0] is ProfileUiState.Loading)
        assertTrue(states[1] is ProfileUiState.Success)
        assertEquals("sudarshan", (states[1] as ProfileUiState.Success).user.name)
    }

    @Test
    fun `saveDraft emits ValidationError when name empty`() = runTest {
        val viewModel = ProfileViewModel(fakeRepository)
        val states = mutableListOf<ProfileUiState>()

        viewModel.uiState.collect { states.add(it) }

        viewModel.onNameChanged("")
        viewModel.onSaveClick()

        advanceUntilIdle()

        val editingState = states.last() as ProfileUiState.ViewMode.Editing
        assertTrue(editingState is ProfileUiState.ViewMode.Editing.ValidationError)
        assertEquals("Name required", (editingState as ProfileUiState.ViewMode.Editing.ValidationError).errors["name"])
    }
}

Each test reads like a spec. The

code
when
casting is safe because the sealed hierarchy guarantees the variant exists.

[!NOTE] Sealed hierarchies work seamlessly with

code
StateFlow
and
code
SharedFlow
. The UI collects a single
code
StateFlow<ProfileUiState>
and reacts to every variant without type erasure issues.

Mapping Repository Results to UI State

Repository layers often return

code
Result<T>
or sealed result types. Map them directly into UI state variants at the ViewModel boundary. Keep the mapping explicit and testable.

kotlin
@HiltViewModel
class ProfileViewModel @Inject constructor(
    private val userRepository: UserRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow<ProfileUiState>(ProfileUiState.Loading())
    val uiState: StateFlow<ProfileUiState> = _uiState

    init { loadUser() }

    private fun loadUser() {
        viewModelScope.launch {
            _uiState.value = ProfileUiState.Loading()
            userRepository.getCurrentUser()
                .onSuccess { user ->
                    _uiState.value = ProfileUiState.ViewMode.ReadOnly(user)
                }
                .onFailure { error ->
                    _uiState.value = ProfileUiState.Error(
                        message = error.userMessage,
                        retryAction = { loadUser() }
                    )
                }
        }
    }

    fun onEditClick() {
        _uiState.value = _uiState.value.let { current ->
            when (current) {
                is ProfileUiState.ViewMode.ReadOnly -> ProfileUiState.ViewMode.Editing.InProgress(current.user.toDraft())
                else -> current
            }
        }
    }

    fun onNameChanged(name: String) {
        _uiState.value = _uiState.value.let { current ->
            when (current) {
                is ProfileUiState.ViewMode.Editing.InProgress ->
                    current.copy(draft = current.draft.copy(name = name))
                is ProfileUiState.ViewMode.Editing.ValidationError ->
                    current.copy(draft = current.draft.copy(name = name))
                else -> current
            }
        }
    }

    fun onSaveClick() {
        _uiState.value = _uiState.value.let { current ->
            when (current) {
                is ProfileUiState.ViewMode.Editing.InProgress -> {
                    val validationErrors = validateDraft(current.draft)
                    if (validationErrors.isNotEmpty()) {
                        ProfileUiState.ViewMode.Editing.ValidationError(current.draft, validationErrors)
                    } else {
                        ProfileUiState.ViewMode.Saving(current.draft)
                    }
                }
                else -> current
            }
        }

        // Trigger actual save after state update
        if (_uiState.value is ProfileUiState.ViewMode.Editing.Saving) {
            saveDraft((_uiState.value as ProfileUiState.ViewMode.Editing.Saving).draft)
        }
    }

    private fun saveDraft(draft: UserDraft) {
        viewModelScope.launch {
            userRepository.updateProfile(draft)
                .onSuccess { user ->
                    _uiState.value = ProfileUiState.ViewMode.Editing.Saved(user)
                    // Auto-transition back to read-only after delay
                    delay(1500)
                    _uiState.value = ProfileUiState.ViewMode.ReadOnly(user)
                }
                .onFailure { error ->
                    _uiState.value = ProfileUiState.ViewMode.Editing.ValidationError(
                        draft = draft,
                        errors = mapOf("server" to error.userMessage)
                    )
                }
        }
    }

    private fun validateDraft(draft: UserDraft): Map<String, String> {
        val errors = mutableMapOf<String, String>()
        if (draft.name.isBlank()) errors["name"] = "Name required"
        if (draft.email.isBlank() || !draft.email.contains("@")) errors["email"] = "Valid email required"
        return errors
    }
}

The ViewModel owns the state machine. Every transition is a deliberate assignment to

code
_uiState.value
. No hidden side effects. The UI stays dumb — it just renders what the state says.

[!IMPORTANT] Keep state transitions in the ViewModel. Don't let Compose components mutate state directly. Single source of truth prevents race conditions and makes debugging trivial — just log

code
_uiState.value
changes.

Handling Navigation Events Without Breaking Unidirectional Flow

Some UI states imply navigation — "go back after save," "open settings," "show login." Don't stuff navigation commands into UI state. Use a separate

code
Effect
channel.

kotlin
sealed interface ProfileEffect {
    data class NavigateToSettings(val deepLink: String? = null) : ProfileEffect
    data class NavigateBack(val result: Boolean = true) : ProfileEffect
    data class ShowSnackbar(val message: String) : ProfileEffect
    object DismissDialog : ProfileEffect
}

@HiltViewModel
class ProfileViewModel @Inject constructor(...) : ViewModel() {
    private val _effects = Channel<ProfileEffect>(Channel.UNLIMITED)
    val effects: ReceiveChannel<ProfileEffect> = _effects

    // ... inside save success:
    _effects.send(ProfileEffect.NavigateBack())
    _effects.send(ProfileEffect.ShowSnackbar("Profile updated"))
}
kotlin
@Composable
fun ProfileScreen(viewModel: ProfileViewModel = hiltViewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    val scope = rememberCoroutineScope()

    LaunchedEffect(viewModel) {
        viewModel.effects.consumeEach { effect ->
            when (effect) {
                is ProfileEffect.NavigateToSettings -> navController.navigate("settings")
                is ProfileEffect.NavigateBack -> navController.popBackStack()
                is ProfileEffect.ShowSnackbar -> snackbarHostState.showSnackbar(effect.message)
                ProfileEffect.DismissDialog -> dialogDismissed()
            }
        }
    }

    // ... render uiState
}

Effects are one-time events. They don't persist in state. The UI consumes them once and moves on. This keeps the state hierarchy pure — only renderable state lives there.

Key Takeaways

  • Model every valid UI state as a sealed variant with required data. The compiler prevents illegal combinations like
    code
    Success
    without a user or
    code
    Error
    without a retry action.
  • Nest sealed interfaces for sub-flows. Group related variants (editing, loading, empty) under a parent sealed interface. Each level gets its own exhaustiveness checking.
  • Keep state transitions in the ViewModel. Assign to
    code
    MutableStateFlow
    explicitly. Log every change for instant debugging. Never let Compose mutate state directly.
  • Separate navigation and side effects into an
    code
    Effect
    channel.
    UI state stays pure and renderable. Effects are consumed once and discarded.
  • Write tests that construct exact state variants. No null checks, no invalid combos. Tests read like executable specifications.
Share:
S

Sudarshan Chaudhari

AI Systems Builder / Product Engineer

Bangkok, Thailand

Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

MyFamilyTracker

Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus

Apps tagged with this