Beyond Loading, Success, and Error: Modeling Complex UI States in Compose
This article explains why the simple Loading/Success/Error pattern is insufficient for Android Compose pages, demonstrates how to model a comprehensive immutable UI snapshot with data classes, sealed types, and StateFlow, and provides practical guidelines for state ownership, restoration, performance, and testing.
Compose developers often start with a sealed interface like FeedUiState that only has Loading, Success, and Error states. While this works for mutually exclusive pages, it breaks when a product adds a common feature such as pull‑to‑refresh.
During a refresh the page still shows existing content, so the UI needs a distinct "refreshing" flag that coexists with the initial load state. Adding separate sealed objects for every nuance quickly inflates the sealed hierarchy, while a set of booleans ( isLoading, isRefreshing, hasError, hasContent) creates impossible combinations.
UI state is an immutable snapshot of everything required to render the UI at a given moment.
A UI snapshot may include the article list, a refresh indicator, the current filter, and pending messages—all of which can appear simultaneously. The article demonstrates building such a snapshot with a data class:
data class FeedUiState(
val articles: List<ArticleUiModel> = emptyList(),
val initialLoad: InitialLoad = InitialLoad.Loading,
val isRefreshing: Boolean = false,
val filter: FeedFilter = FeedFilter.All,
val pendingMessages: List<UiMessage> = emptyList()
)
sealed interface InitialLoad {
data object Loading : InitialLoad
data object Ready : InitialLoad
data class Failed(val reason: FeedError) : InitialLoad
}
enum class FeedFilter { All, Following }
enum class FeedError { Offline, Unknown }
enum class UserMessage { RefreshFailed, ContentUpdateFailed }
data class UiMessage(val id: Long, val type: UserMessage) InitialLoaddescribes the first‑time load outcome, while isRefreshing indicates an ongoing update. These dimensions are independent, so they should not be forced into a single mutually exclusive sealed type.
All properties are exposed as val to guarantee the snapshot cannot be mutated directly. However, val does not make the underlying collection immutable; a MutableList could still be modified elsewhere. The article recommends using read‑only List interfaces for external exposure and mutableStateListOf() when the UI needs to observe per‑item changes.
When a state is truly mutually exclusive (e.g., a checkout page), a sealed hierarchy remains appropriate:
sealed interface CheckoutUiState {
data object Loading : CheckoutUiState
data class Ready(val order: OrderUiModel) : CheckoutUiState
data class Failed(val reason: CheckoutError) : CheckoutUiState
}For a feed page, however, the model must combine persistent content with transient flags, so a data class plus small sealed pieces (like InitialLoad) is the optimal compromise.
Derived values such as totalPrice or canCheckout should be computed from the source data rather than stored separately, avoiding synchronization bugs. When the input changes far more often than the output, wrap the computation in derivedStateOf to reduce recomposition.
The article then shows the full data flow:
Repository → ViewModel → StateFlow → Route → Screen
↑ ↓
business logic ← user actionsThe FeedViewModel combines the article stream, refresh flag, filter, and pending messages into a single uiState StateFlow using combine. It also demonstrates proper error handling: if the first load fails, InitialLoad.Failed is emitted; if content already exists, the error is turned into a non‑blocking message while preserving the list.
private val articleStreamState = repository.observeArticles()
.map { articles ->
hasReceivedArticles = true
latestArticles = articles
ArticleStreamState.Ready(articles)
}
.onStart { if (!hasReceivedArticles) emit(ArticleStreamState.Loading) }
.catch { error ->
if (hasReceivedArticles) {
enqueueMessage(UserMessage.ContentUpdateFailed)
emit(ArticleStreamState.Ready(latestArticles))
} else {
emit(ArticleStreamState.Failed(error.toFeedError()))
}
}
val uiState = combine(
articleStreamState,
isRefreshing,
filter,
pendingMessages
) { articleState, refreshing, selectedFilter, messages ->
when (articleState) {
ArticleStreamState.Loading -> FeedUiState(
initialLoad = InitialLoad.Loading,
isRefreshing = refreshing,
filter = selectedFilter,
pendingMessages = messages
)
is ArticleStreamState.Ready -> FeedUiState(
articles = articleState.articles.applyFilter(selectedFilter).map(Article::toUiModel),
initialLoad = InitialLoad.Ready,
isRefreshing = refreshing,
filter = selectedFilter,
pendingMessages = messages
)
is ArticleStreamState.Failed -> FeedUiState(
initialLoad = InitialLoad.Failed(articleState.reason),
isRefreshing = refreshing,
filter = selectedFilter,
pendingMessages = messages
)
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), FeedUiState())State ownership follows a simple rule: lift the state to the lowest common ancestor of all composables that read or modify it, and keep it as close as possible to its usage. UI‑only flags stay in the composable with rememberSaveable; anything that requires business logic or data‑layer access lives in a ViewModel.
Navigation and one‑time events are handled with a Channel or SharedFlow that emits UiMessage objects. The composable FeedMessageEffect observes the first pending message, shows a Snackbar, and notifies the ViewModel to remove the message after it is displayed.
@Composable
fun FeedMessageEffect(
message: UiMessage?,
snackbarHostState: SnackbarHostState,
onMessageShown: (Long) -> Unit
) {
val text = when (message?.type) {
UserMessage.RefreshFailed -> stringResource(R.string.feed_refresh_failed)
UserMessage.ContentUpdateFailed -> stringResource(R.string.feed_content_update_failed)
else -> null
}
LaunchedEffect(message?.id) {
if (message != null && text != null) {
snackbarHostState.showSnackbar(text)
onMessageShown(message.id)
}
}
}For navigation that depends on business validation, the ViewModel writes the result into the UI state, and the composable decides whether to navigate.
State restoration uses rememberSaveable for UI‑local values and SavedStateHandle for ViewModel‑owned inputs such as the current filter:
private const val FILTER_KEY = "feed_filter"
val filter: StateFlow<FeedFilter> = savedStateHandle.getStateFlow(FILTER_KEY, FeedFilter.All)
fun onFilterChanged(filter: FeedFilter) { savedStateHandle[FILTER_KEY] = filter }SharingStarted controls how long a StateFlow stays active after the last subscriber disappears; WhileSubscribed(5_000) keeps the upstream alive for five seconds to avoid unnecessary restarts during configuration changes.
Testing is split into two parts: unit‑testing the ViewModel’s state generation (e.g., verifying that a refresh failure keeps content and queues a message) and UI‑testing the composable screen with a known FeedUiState to assert that the list, refresh indicator, and messages appear as expected.
In conclusion, effective Compose state management does not require a heavyweight MVI framework. The key is to define a clear immutable snapshot that contains exactly the information the UI needs at any moment, place the state holder as close to its consumers as possible, expose only read‑only data, and let the UI render purely from that snapshot.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Sohu Tech Products
A knowledge-sharing platform for Sohu's technology products. As a leading Chinese internet brand with media, video, search, and gaming services and over 700 million users, Sohu continuously drives tech innovation and practice. We’ll share practical insights and tech news here.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
