Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 026 / 100

UI state modeling: a Android & Kotlin guide for developers

One source of truth per screen.

Expona Admin7 min read

Introduction

After two decades of mobile development patterns, we've learned that UI crashes, data inconsistencies, and mysterious blank screens usually stem from the same root cause: multiple sources of truth for what's displayed on screen. UI state modeling: a Android & Kotlin guide for developers covers the architecture and practices that separate well-maintained production apps from those that become maintenance nightmares. This day focuses on how to structure your screen state so that one source of truth drives all visual output.

Data class with all screen state

The foundation of solid UI state management is a single immutable data class that represents everything visible on a given screen at any moment. This isn't a suggestion—it's the difference between predictable and chaotic rendering.

Consider a user profile screen. You need to display the user's avatar, name, bio, follower count, and a loading indicator. You might be tempted to hold these as separate properties in your ViewModel, but that creates drift. Instead, define a sealed data class:

kotlin
data class UserProfileState(
    val userId: String,
    val name: String = "",
    val avatar: String = "",
    val bio: String = "",
    val followerCount: Int = 0,
    val isFollowing: Boolean = false,
    val isLoading: Boolean = false,
    val errorMessage: String? = null
)

Your ViewModel emits a single StateFlow of this class. Every UI update comes through one channel. The composable function receives this state object and renders accordingly:

kotlin
@Composable
fun UserProfileScreen(viewModel: UserProfileViewModel) {
    val state by viewModel.uiState.collectAsState()
    
    Box {
        if (state.isLoading) {
            CircularProgressIndicator()
        } else if (state.errorMessage != null) {
            ErrorBanner(state.errorMessage)
        } else {
            ProfileContent(
                name = state.name,
                avatar = state.avatar,
                bio = state.bio,
                followerCount = state.followerCount,
                isFollowing = state.isFollowing
            )
        }
    }
}

This pattern ensures the UI never gets confused about whether data is present or what state it's in. No race conditions where the loading flag clears before the data arrives. No duplicate fields holding similar information.

Sealed classes for loading and error states

Boolean flags for loading and error handling become unwieldy quickly. A screen with three independent async operations using booleans gives you six possible conflicting states. Use sealed classes instead to model only the states that are actually valid.

Replace the isLoading and errorMessage approach with a sealed type that represents the data lifecycle:

kotlin
sealed class UserProfileState {
    object Loading : UserProfileState()
    
    data class Success(
        val userId: String,
        val name: String,
        val avatar: String,
        val bio: String,
        val followerCount: Int,
        val isFollowing: Boolean
    ) : UserProfileState()
    
    data class Error(val exception: Exception) : UserProfileState()
}

Now your composable uses a simple when expression that the compiler ensures is exhaustive:

kotlin
@Composable
fun UserProfileScreen(viewModel: UserProfileViewModel) {
    val state by viewModel.uiState.collectAsState()
    
    when (state) {
        is UserProfileState.Loading -> CircularProgressIndicator()
        is UserProfileState.Error -> ErrorBanner(state.exception.message ?: "Unknown error")
        is UserProfileState.Success -> ProfileContent(
            name = state.name,
            avatar = state.avatar,
            bio = state.bio,
            followerCount = state.followerCount,
            isFollowing = state.isFollowing
        )
    }
}

This approach eliminates impossible states by construction. You cannot accidentally render a loading spinner with stale success data. The type system enforces correctness.

Derive values instead of duplicating

As screens grow in complexity, you'll be tempted to precompute and store derived values in your state class. Resist this. Derive them from the source state instead, and your codebase stays maintainable.

Imagine your profile screen needs to show a formatted follower count ("1.2M followers") and also highlight whether the user is a verified celebrity based on follower count. Don't store these in state:

kotlin
// Anti-pattern: storing derived values
data class UserProfileState(
    val followerCount: Int,
    val formattedFollowerCount: String,  // Derived, don't store
    val isVerified: Boolean,              // Derived, don't store
    val isFollowing: Boolean
)

Instead, compute them in the composable or in a view model extension function:

kotlin
data class UserProfileState(
    val followerCount: Int,
    val isFollowing: Boolean
)

@Composable
fun UserProfileContent(state: UserProfileState.Success) {
    val formattedFollowers = formatFollowerCount(state.followerCount)
    val isVerified = state.followerCount > 100_000
    
    Column {
        Text("$formattedFollowers followers")
        if (isVerified) {
            Badge("Verified")
        }
    }
}

fun formatFollowerCount(count: Int): String = when {
    count >= 1_000_000 -> "${count / 1_000_000}M"
    count >= 1_000 -> "${count / 1_000}K"
    else -> count.toString()
}

If the formatting logic or verification threshold changes, you update one place. Derived values computed from state never diverge from the truth. Your state class stays lean and focused on the actual data layer, not presentation logic.

Events as one-off effects carefully

Not everything in your UI is state. Some things happen once: navigate to another screen, show a one-time toast, trigger analytics. These are events or effects, and they need different handling than state.

StateFlow is wrong for events because replayed emissions cause duplicate side effects. A new subscriber to your StateFlow receives the last value immediately, which is perfect for state but catastrophic for a navigation event. Use a Channel or SharedFlow configured properly:

kotlin
class UserProfileViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(UserProfileState.Loading)
    val uiState: StateFlow = _uiState.asStateFlow()
    
    // Use Channel for one-time events
    private val _events = Channel(Channel.BUFFERED)
    val events: Flow = _events.receiveAsFlow()
    
    fun followUser() {
        viewModelScope.launch {
            try {
                repository.followUser()
                _events.send(UserProfileEvent.FollowSuccess)
                // Also update state for immediate UI feedback
                _uiState.update { (it as? UserProfileState.Success)?.copy(isFollowing = true) ?: it }
            } catch (e: Exception) {
                _events.send(UserProfileEvent.Error(e.message ?: "Failed"))
            }
        }
    }
}

sealed class UserProfileEvent {
    object FollowSuccess : UserProfileEvent()
    data class Error(val message: String) : UserProfileEvent()
    data class NavigateTo(val route: String) : UserProfileEvent()
}

In your composable, collect events separately and handle them with LaunchedEffect:

kotlin
@Composable
fun UserProfileScreen(viewModel: UserProfileViewModel, onNavigate: (String) -> Unit) {
    val state by viewModel.uiState.collectAsState()
    
    LaunchedEffect(Unit) {
        viewModel.events.collect { event ->
            when (event) {
                is UserProfileEvent.FollowSuccess -> {
                    // Show toast or snackbar
                }
                is UserProfileEvent.Error -> {
                    // Show error dialog
                }
                is UserProfileEvent.NavigateTo -> onNavigate(event.route)
            }
        }
    }
    
    // Render state normally
    when (state) { ... }
}

Keep events minimal and focused. They're glue between your business logic and one-off UI actions, not replacements for state. State drives what's on screen; events trigger isolated actions.

Common mistakes

Mixing state and events in one flow. Using a single SharedFlow for both rendering data and triggering navigation leads to lost events on configuration changes or leaked effects when the UI recomposes.

Mutating state objects. If your state is a mutable list or map, changes can happen outside your controlled update path. Always use immutable collections and update through the ViewModel's state management.

Storing ViewModels in mutable state. Holding a ViewModel instance in a Composable's remember block or as a var can cause memory leaks. Always retrieve it from the composition via viewModel() or inject it through the parameter.

Forgetting to debounce or throttle repeated state changes. If your repository emits too frequently (e.g., every keystroke), state updates flood the UI thread. Use debounce or throttle on your upstream flows before feeding them into state.

Over-modeling edge cases. Not every possible combination of data needs its own sealed subclass. If a field is nullable or a boolean, sometimes it's acceptable to leave those in a Success state rather than proliferate sealed variants.

Key takeaways

  • Define a single immutable data class or sealed class hierarchy per screen to represent all visible state.
  • Use sealed classes for loading and error states instead of boolean flags; the compiler enforces exhaustiveness.
  • Compute derived values in the UI layer, not in state, so changes to formatting or logic don't require state migrations.
  • Separate events from state using Channels or configured SharedFlows to prevent duplicate side effects.
  • Make state updates go through a single controlled path (ViewModel) to maintain consistency and testability.
  • Collect state in one composable subscription and events in a separate LaunchedEffect to keep concerns distinct.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles