Stop Modeling Every Screen as Loading-Success-Error
The Loading-Success-Error pattern is taught in official samples and copied everywhere, but it silently breaks on the first real-world feature that requires two simultaneous states. Recognizing that page state is a multi-dimensional snapshot rather than a single enum prevents a cascade of architectural patches — extra sealed variants, boolean flags, and event channels — that make Compose code harder to reason about and test.
Most Compose developers reach for a sealed interface with Loading, Success, and Error variants. That model works until pull-to-refresh arrives: the screen has content but is also loading, and a refresh failure shouldn't wipe the article list. Adding Refreshing and RefreshFailed variants bloats the sealed class; replacing it with booleans creates impossible state combinations.
The fix is to treat UI state as an immutable snapshot that can hold multiple simultaneous dimensions. A Feed page gets a data class with articles, an InitialLoad sealed type (Loading / Ready / Failed), an isRefreshing boolean, a filter, and a pendingMessages list. Truly mutually exclusive sub-states stay sealed; everything else coexists. Derived values like totalPrice or canCheckout are computed properties, never stored separately.
State flows from Repository through ViewModel to a StateFlow, collected with collectAsStateWithLifecycle in a thin Route composable. The Screen receives only data and lambdas — no ViewModel reference. One-shot events like refresh failures are reduced into the state as a message list with unique IDs; the UI shows them and calls back to remove them. Process-death recovery saves only minimal inputs (filter keys, query strings) via SavedStateHandle, then regenerates full state from the data layer.
Official Compose samples and tutorials have normalized the Loading-Success-Error sealed class to the point where many developers treat it as the only correct pattern, even though it fails on the most common mobile UX pattern — pull-to-refresh.
The distinction between page state (business-driven, affects the whole screen) and UI element state (local, like a card's expanded toggle) is rarely taught explicitly, leading to ViewModels bloated with local UI concerns that should stay in rememberSaveable.
Using a message list with unique IDs rather than a Channel for one-shot UI events sidesteps the lifecycle mismatch problem entirely: the event is just part of the state snapshot, so it survives recomposition and configuration changes without special delivery semantics.
Strong Skipping has been default since Kotlin 2.0.20, which means stability annotations and collection optimizations should be a measured response to a confirmed bottleneck, not a preemptive ritual applied to every Composable.
The frontend can manage this with just two variables, and list pages usually need an empty state. const PageStatus = { None: 0, Content: 1 << 0, Empty: 1 << 1, Error: 1 << 2, } as const type LoadingStatus = true|false
This is exactly the first approach, treating the current page state as mutually exclusive. So how do you handle the success or failure of a pull-to-refresh?