跪拜 Guibai
← All articles
Kotlin · Android

Stop Modeling Every Screen as Loading-Success-Error

By RockByte ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

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.

Summary

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.

Takeaways
Pull-to-refresh exposes the flaw in Loading | Success | Error: the screen has content (Success) but is also loading (Refreshing), so a single mutually exclusive type cannot represent both.
Model page state as a data class holding multiple simultaneous dimensions, with sealed types only for sub-states that are truly mutually exclusive, like InitialLoad.
Derived values such as totalPrice or canCheckout should be computed properties on the state class, never stored as separate fields that can drift out of sync.
Use a list of messages with unique IDs for non-blocking errors rather than a single nullable field; the UI shows each message and calls back to remove it from state.
Collect state with collectAsStateWithLifecycle() in a Route composable; the Screen receives only immutable data and callback lambdas, never a ViewModel reference.
Save only minimal inputs (filter keys, query strings) via SavedStateHandle for process-death recovery; regenerate full page state from the data layer afterward.
Test state generation and UI rendering separately: assert that the ViewModel produces correct state snapshots, then pass those snapshots to a stateless Screen and verify what the user sees.
Don't annotate with @Stable or @Immutable to silence compiler hints; fix actual mutability first, and measure performance before optimizing stability.
Conclusions

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.

Concepts & terms
derivedStateOf
A Compose API that creates a derived state object which only triggers recomposition when its computed output changes, not on every input change. Useful when input changes frequently (e.g., scroll position) but the output changes rarely (e.g., 'is scrolled past first item').
SharingStarted.WhileSubscribed
A sharing strategy for StateFlow that keeps the upstream flow active while there are subscribers, plus a configurable timeout after the last subscriber disappears. The 5-second default buffer prevents restarting the flow during brief lifecycle pauses like configuration changes.
Strong Skipping
A Compose compiler optimization enabled by default since Kotlin 2.0.20 that allows composable functions to skip recomposition even when they receive unstable parameters, as long as the instances haven't changed. It reduces the need for manual stability annotations.
SnapshotStateList
A mutable list type created by mutableStateListOf() that is observable by the Compose snapshot system. Unlike a plain MutableList, additions and removals from a SnapshotStateList trigger recomposition in composables that read it.
From the discussion
Featured comments
似水流年146

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

RockByte

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?

See top comments, translated →
Source: juejin.cn ↗ Google Translate ↗ Backup ↗