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.
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.
On this page
I used to model UI state with enums and nullable fields. Every screen had a
statusLOADINGSUCCESSERRORuserDatastatus == LOADINGEnums feel natural at first. You define
LoadingSuccessError// 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
ProfileUiState(status = Success, user = null)[!WARNING] Flat state classes with nullable fields create implicit invariants the compiler cannot enforce. Every read site becomes a potential crash site.
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
whensealed 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
ProfileUiState.SuccessUserProfileUiState.ErrorSuccess@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
elsewhen[!TIP] Use
instead ofcodesealed interfacewhen you want to allow multiple inheritance or mix in other interfaces later. It's the modern default since Kotlin 1.5.codesealed class
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.
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
ViewModeEditing@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
whenEditingProfile| Approach | Illegal States Possible | Exhaustiveness Checking | Data Per Variant | Nesting Support |
|---|---|---|---|---|
| Enum + nullable fields | Yes | No | No (shared fields) | Awkward |
| Sealed class (single level) | No | Yes | Yes | Manual |
| Nested sealed interfaces | No | Yes | Yes | Native |
Because each state variant is a distinct type with required data, tests construct exactly what they need. No null-check noise. No invalid combinations.
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
when[!NOTE] Sealed hierarchies work seamlessly with
andcodeStateFlow. The UI collects a singlecodeSharedFlowand reacts to every variant without type erasure issues.codeStateFlow<ProfileUiState>
Repository layers often return
Result<T>@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
_uiState.value[!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
changes.code_uiState.value
Some UI states imply navigation — "go back after save," "open settings," "show login." Don't stuff navigation commands into UI state. Use a separate
Effectsealed 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"))
}@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.
SuccessErrorMutableStateFlowEffectSudarshan 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.
Related Posts
Related Apps
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 meComments — powered by Giscus
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
ReadPrivate dream journal — structured entry capture, pattern tagging, and optional Claude-powered insight generation. All data stays on-device by default.
ReadWorkout tracker — exercise logging with set/rep/weight history, goal progression, and local Room DB persistence. No account, no cloud sync required.
Read