跪拜 Guibai
← Back to the summary

Stop Modeling Every Screen as Loading-Success-Error

0.png

Hello Compose developers, happy Friday. Taking advantage of this pleasant Friday, let's discuss a question together: How exactly should UI State in Compose be written?

I believe that when using Compose, most developers easily write page states like this:

sealed interface FeedUiState {
    data object Loading : FeedUiState
    data class Success(
        val articles: List<ArticleUiModel>,
    ) : FeedUiState
    data class Error(
        val reason: FeedError,
    ) : FeedUiState
}

This approach looks very clear; a page is nothing more than three states: loading, success, and error.

Frankly, there is nothing wrong with this approach. Many official code samples are written this way because many page states are indeed mutually exclusive.

But! If the product proposes a very common requirement: pull-to-refresh. Here comes the problem.

As everyone knows, pull-to-refresh does not require a global Loading indicator. That is, the data still exists, just with a Loading state different from the initial Loading.

If we follow the previous division, I ask you:

From a requirements perspective, the answer to each question is very clear. But for developers, if we continue using the above solution, there will be some minor issues.

We can certainly continue adding states like Refreshing, RefreshFailed, etc., but if the page is slightly more complex, the sealed class will quickly bloat.

What about using a set of booleans?

It's no better: isLoading, isRefreshing, hasError, and hasContent can combine into many states that are impossible in business logic.

At this point, we might realize that we misunderstood the page state from the beginning.

UI State is an immutable snapshot of all the information required for the UI to complete rendering at a specific moment.

It is not the result of a single request, nor does it necessarily have to be chosen from a few mutually exclusive states. The content currently displayed on the page, the refresh progress, filter conditions, and messages pending display can all belong to this snapshot simultaneously.

Next, starting from an imagined Feed page, let's see where the state should be placed, how to model it, how it should flow to the UI, and finally handle state restoration, performance, and testing.

Coincidentally, the imagined Feed page basically uses the data structure above.

Where to Place State

Alright, I know this involves StateFlow or MVI, but before that, we need to solve a more fundamental problem: Who should hold the state?

Put all Compose state into ViewModel? Never hoist if you can use remember?

I'll first provide a more practical judgment method:

Hoist state to the lowest common ancestor of all Composables that read and modify it, and try to keep it close to where it is actually used.

If this state requires participation from business logic or the data layer, the lowest common ancestor can also be outside the composition, at which point the ViewModel acts as the page-level state holder.

Page State and UI Element State Are Not the Same Thing

Page UI state usually comes from business logic or is generated from data in the Repository. For example:

This type of state affects the entire page and often requires access to the data layer.

In Android projects, data is typically managed by the Repository, processed by the ViewModel, and then exposed to the UI layer for use.

UI element state, on the other hand, controls the presentation of a specific interface element. For example:

For instance, whether a card is expanded only affects that card itself, so it can stay directly in the corresponding Composable:

@Composable
fun ExpandableArticleCard(
    article: ArticleUiModel,
    modifier: Modifier = Modifier,
) {
    var expanded by rememberSaveable(article.id) {
        mutableStateOf(false)
    }

    ArticleCard(
        article = article,
        expanded = expanded,
        onExpandClick = { expanded = !expanded },
        modifier = modifier,
    )
}

There is no need to create a ViewModel specifically for this.

If later multiple child components need to read or modify expanded, then hoist it to the lowest common ancestor of those components. If the local UI logic continues to become more complex, it can also be extracted into a plain state holder class.

The usage boundary for ViewModel is now clear: hoist state to ViewModel when it needs to affect the entire page, requires business logic participation, or needs access to the data layer.

Don't stuff all local toggles into ViewModel just because it can survive configuration changes.

Wait, let me clarify here: What is business logic, and what is pure UI logic.

Roughly speaking, requirements described by the product manager are usually closer to business logic, while interactions and visual presentations described by the designer are usually closer to UI logic.

However, this can only serve as a helpful explanation, not a strict judgment criterion. What really needs to be considered is whether this state participates in business rules, data processing, or needs to cross the lifecycle of a specific local Composable.

Page State Modeling

Okay, now back to the Feed page, let's solve the real problem.

The page might be in the initial loading phase, or it might already have content; when it has content, it might also be refreshing.

A refresh failure does not mean the old content must disappear, and a pending Snackbar can coexist with the article list.

Therefore, a single Loading | Success | Error covering the entire page is not suitable here.

First, Describe the Page as an Immutable Snapshot

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,
)

InitialLoad describes "whether the page content can be obtained for the first time", and isRefreshing describes "whether the existing page is currently updating".

These two things are not on the same dimension, so there is no need to force them into the same mutually exclusive state.

The properties here are all exposed externally via val and read-only types. The UI cannot reassign them, nor can it directly modify the collection through the List interface.

However, val does not equal deep immutability:

val articles: List<ArticleUiModel>

It only guarantees that the variable itself cannot be reassigned and that callers can only see the read-only List interface. If the underlying layer still holds the same MutableList, other code may still modify the collection content. Therefore, when generating a new state, you must also avoid handing a mutable collection that will be modified later directly to the UI.

If the collection itself is local UI state and indeed needs to respond to changes item by item, you can use mutableStateListOf() to create an observable SnapshotStateList. It is not the same as a regular MutableList: additions and deletions to a SnapshotStateList can be observed by Compose.

The following should also not be exposed:

// Not recommended
val articles: MutableList<ArticleUiModel>
val selectedIds: MutableSet<String>
val uiState: MutableStateFlow<FeedUiState>

Mutable collections can bypass state updates and directly change the data the page is using. Exposing MutableStateFlow brings the same problem: callers can bypass the true state holder and directly rewrite the state.

Once multiple modification entry points appear in a project, the "single source of truth" becomes nothing more than a slogan.

In plain terms, if the state holds a regular MutableList and data is subsequently added or deleted directly on the original collection, Compose usually cannot recognize this change as a new state update. You added data in the ViewModel, but the page may not refresh as expected.

Immutability primarily solves the correctness problem. It can indeed help Compose determine whether inputs have changed, but you shouldn't casually add @Immutable just to eliminate hints in the compiler report. This annotation is just a stability promise we make to the compiler; it won't automatically turn a mutable type into an immutable one.

Data Classes and Sealed Hierarchies Are Not an Either-Or Choice

If several states of a page are truly mutually exclusive, a sealed hierarchy is still very suitable:

sealed interface CheckoutUiState {
    data object Loading : CheckoutUiState
    data class Ready(val order: OrderUiModel) : CheckoutUiState
    data class Failed(val reason: CheckoutError) : CheckoutUiState
}

A checkout page cannot be in Loading and Ready simultaneously. Using the type system can directly prevent such invalid states from being created.

In this simplified checkout example, the three page phases Loading, Ready, and Failed are mutually exclusive, so using a sealed hierarchy is very appropriate.

But the Feed page is different. It needs to simultaneously express:

This type of page is more suitable for using a data class, and then consolidating the truly mutually exclusive small-scope states into a sealed type. The earlier InitialLoad is doing exactly this.

So, the real judgment criterion is not "data classes are simpler" or "sealed classes are safer", but whether the states on the page are mutually exclusive or need to exist simultaneously.

Don't Save a Value That Can Be Calculated

Another very common problem in state modeling is saving the same fact multiple times.

The solution is also very simple:

data class CartUiState(
    val items: List<CartItemUiModel> = emptyList(),
) {
    val totalPrice: Money
        get() = items.fold(Money.Zero) { total, item ->
            total + item.totalPrice
        }

    val canCheckout: Boolean
        get() = items.isNotEmpty()
}

totalPrice and canCheckout can both be derived from items. If these two were saved as separate data, you would have to handle synchronization issues among the three, potentially allowing the construction of contradictory states like "the cart is empty, but checkout is allowed".

The same principle applies to Composables. However, being calculable doesn't mean everything needs a layer of derivedStateOf:

val showScrollToTop by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }
}

The list scroll position changes very frequently, but the UI only cares about "whether it has left the first item". This scenario, where the input changes frequently but the output changes rarely, is very suitable for derivedStateOf.

If you are just concatenating two strings that rarely change, direct calculation is usually simpler. derivedStateOf itself has overhead; you don't need to add a layer to every derived value.

There is a judgment criterion: if the number of input changes is greater than the number of output changes, then use derivedStateOf.

Think about the example above: list scroll position changes versus string concatenation, isn't that the logic?

The Flow of State

1.png

After the state model is determined, the next step is to connect the complete data chain:

Repository → ViewModel → StateFlow → Route → Screen
                   ↑                    ↓
              Business Logic    ←   User Actions

State flows from top to bottom, user actions are passed from bottom to top. The ViewModel processes business logic and generates new state, and the Composable only renders the page based on the state.

Converging Repository, Refresh State, and Restored State

Let's continue completing the Feed example:

private const val FILTER_KEY = "feed_filter"

private sealed interface ArticleStreamState {
    data object Loading : ArticleStreamState
    data class Ready(val articles: List<Article>) : ArticleStreamState
    data class Failed(val reason: FeedError) : ArticleStreamState
}

class FeedViewModel(
    private val repository: FeedRepository,
    private val savedStateHandle: SavedStateHandle,
) : ViewModel() {

    private val isRefreshing = MutableStateFlow(false)
    private val pendingMessages =
        MutableStateFlow<List<UiMessage>>(emptyList())

    private var nextMessageId = 0L
    private var hasReceivedArticles = false
    private var latestArticles: List<Article> = emptyList()

    private val articleStreamState: Flow<ArticleStreamState> =
        repository.observeArticles()
            .map<List<Article>, ArticleStreamState> { 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(
                            reason = error.toFeedError(),
                        ),
                    )
                }
            }

    private val filter: StateFlow<FeedFilter> =
        savedStateHandle.getStateFlow(
            key = FILTER_KEY,
            initialValue = FeedFilter.All,
        )

    val uiState: StateFlow<FeedUiState> = 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(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = FeedUiState(),
    )

    fun refresh() {
        if (!isRefreshing.compareAndSet(
                expect = false,
                update = true,
            )
        ) {
            return
        }

        viewModelScope.launch {
            try {
                repository.refresh()
            } catch (_: IOException) {
                enqueueMessage(UserMessage.RefreshFailed)
            } finally {
                isRefreshing.value = false
            }
        }
    }

    fun onFilterChanged(filter: FeedFilter) {
        savedStateHandle[FILTER_KEY] = filter
    }

    fun onMessageShown(id: Long) {
        pendingMessages.update { messages ->
            messages.filterNot { it.id == id }
        }
    }

    private fun enqueueMessage(type: UserMessage) {
        val message = UiMessage(
            id = nextMessageId++,
            type = type,
        )

        pendingMessages.update { messages ->
            messages + message
        }
    }
}

private fun List<Article>.applyFilter(
    filter: FeedFilter,
): List<Article> = when (filter) {
    FeedFilter.All -> this
    FeedFilter.Following -> {
        filter { article ->
            article.isFromFollowingAuthor
        }
    }
}

private fun Throwable.toFeedError(): FeedError =
    if (this is IOException) {
        FeedError.Offline
    } else {
        FeedError.Unknown
    }

This time, InitialLoad.Failed is no longer just defined but never used. If an exception occurs in the Repository's data flow before the first content is emitted, it generates the corresponding blocking failure state.

Special attention is needed here: catch captures exceptions from the entire upstream Flow; it does not naturally equate to "initial load failure". Therefore, the code first checks via hasReceivedArticles whether content has already been received.

If content has never appeared, the page enters InitialLoad.Failed. If the page has already received content, catch re-emits the last successful data and adds a non-blocking message. This way, subsequent data source exceptions won't clear the existing content.

Also note, after catch emits a fallback value, this upstream collection ends. If this data source needs automatic recovery, you should use retryWhen before catch based on the exception type, or push the retry and backoff strategy down to the Repository; don't mistakenly assume that the original Flow will continue emitting data after catch.

Refresh takes a different path. Here, repository.refresh() only treats IOException as a network-type failure that can be prompted to the user: upon failure, a message is added to pendingMessages, but the article list is not cleared, nor is the entire page switched to an initial load failure. Other unexpected exceptions will not be silently swallowed by this code.

A message list with unique IDs is used here, rather than a single nullable field. Because multiple messages might be generated consecutively in a short period, if the interaction design requires notifying the user of each message, a list can retain every pending message.

If the business allows later messages to overwrite the previous one, it can also be simplified to UserMessage?.

How Long Should StateFlow Stay Active

SharingStarted.WhileSubscribed(5_000) means: as long as there are subscribers, the upstream data flow remains active; after the last subscriber disappears, wait for 5 seconds before stopping the data flow.

This brief wait can prevent lifecycle switches like configuration changes from causing the upstream to stop immediately and then restart shortly after. 5_000 is not a number that all projects must copy; the specific time still depends on the upstream restart cost and how the page is used.

I'm used to extending it appropriately based on the probability of the user returning to the page shortly and the upstream restart cost, but the longer the wait time, the longer upstream resources are held. If the upstream connects to networks, sensors, or expensive queries, this time cannot be arbitrarily extended.

If you want the state to remain active after the first subscriber appears, even when the page temporarily leaves the screen, you can use SharingStarted.Lazily. For example, a tab that is temporarily invisible but the user is likely to switch back to soon might be a suitable scenario for this strategy. However, once the first subscription occurs, it will persist until the viewModelScope is cancelled, and upstream resources will also be held continuously.

Which strategy to choose depends on how long this state needs to live, not on what the project template defaults to.

If the UI state does not depend on a Repository data flow, you can also directly maintain a private MutableStateFlow inside the ViewModel and expose a read-only version externally:

private val _uiState = MutableStateFlow(EditProfileUiState())
val uiState: StateFlow<EditProfileUiState> = _uiState.asStateFlow()

fun onNameChanged(name: String) {
    _uiState.update { current ->
        current.copy(name = name)
    }
}

If the project has been upgraded to Kotlin 2.4.0, you can also use the now-stable Explicit backing fields, making the code a bit shorter:

val uiState: StateFlow<EditProfileUiState> field = MutableStateFlow(EditProfileUiState())

Kotlin 2.3.x already provided this syntax, but it was still an experimental feature at the time; starting from Kotlin 2.4.0, it is officially stable and no longer requires opt-in. For projects not yet upgraded to the corresponding version, continue using _uiState and asStateFlow().

When a new value needs to be calculated based on the current value, using update can atomically complete the "read-modify-write" cycle, preventing concurrent updates from being overwritten by an outdated state snapshot.

Route Handles Wiring, Screen Only Handles Rendering

Reusable Composables do not need to know about the existence of ViewModel.

Passing ViewModel directly into a Screen or list item binds the UI to a specific state holder, making previews, testing, and future changes to the state source cumbersome.

Therefore, the Feed page can first be split into Route and Screen layers:

@Composable
fun FeedRoute(
    onArticleClick: (String) -> Unit,
    viewModel: FeedViewModel = hiltViewModel(),
) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    val snackbarHostState = remember { SnackbarHostState() }

    FeedMessageEffect(
        message = state.pendingMessages.firstOrNull(),
        snackbarHostState = snackbarHostState,
        onMessageShown = viewModel::onMessageShown,
    )

    Scaffold(
        snackbarHost = {
            SnackbarHost(hostState = snackbarHostState)
        },
    ) { contentPadding ->
        FeedScreen(
            state = state,
            onRefresh = viewModel::refresh,
            onFilterChanged = viewModel::onFilterChanged,
            onArticleClick = onArticleClick,
            modifier = Modifier.padding(contentPadding),
        )
    }
}

Route obtains the ViewModel, collects state, and is responsible for coordinating Snackbar and navigation. Screen only receives data and callbacks:

@Composable
fun FeedScreen(
    state: FeedUiState,
    onRefresh: () -> Unit,
    onFilterChanged: (FeedFilter) -> Unit,
    onArticleClick: (String) -> Unit,
    modifier: Modifier = Modifier,
) {
    FeedContent(
        articles = state.articles,
        initialLoad = state.initialLoad,
        isRefreshing = state.isRefreshing,
        selectedFilter = state.filter,
        onRefresh = onRefresh,
        onFilterChanged = onFilterChanged,
        onArticleClick = onArticleClick,
        modifier = modifier,
    )
}

List items similarly only need to receive the data and event lambdas they actually use; there is no need to pass the ViewModel all the way through FeedScreen, LazyColumn, and finally to each ArticleCard.

Although FeedRoute is also a Composable function, its main responsibility is to prepare data for FeedScreen. This cannot be avoided; even the cleanest code has dirty parts. At this point, ensuring the independence and portability of FeedScreen (the true UI rendering) is crucial.

Collecting State

FeedRoute uses collectAsStateWithLifecycle(). In Android Compose pages, it is usually more suitable than collectAsState() because it controls data collection in conjunction with the Android lifecycle. When the UI is no longer in an active state, the collection process can also stop.

The following dependency needs to be added to the project:

dependencies {
    implementation(
        "androidx.lifecycle:lifecycle-runtime-compose:2.11.0"
    )
}

The stable version at the time of this article's publication is directly written here. If the project uses a Version Catalog, it can also be changed to implementation(libs.androidx.lifecycle.runtime.compose). Do not write @latest into the dependency coordinates; it is not a version number that Gradle can resolve.

Additionally, starting from Lifecycle 2.10.0, minSdk has been raised from API 21 to API 23. If the project still needs to support API 21 or 22, you must choose a Lifecycle version compatible with the project's minSdk. The Compose-related artifacts of Lifecycle 2.11.0 also require Compose UI 1.7.0 or higher, and due to compileSdk changes, AGP 9.2.0 is the minimum required.

For platform-independent Compose code that does not depend on the Android lifecycle, you can continue using collectAsState().

Are "One-Shot Events" State

Many projects use Channel or SharedFlow to send navigation, Snackbar, or error events to the UI:

// Not recommended as the default architecture for all UI results
private val events = Channel<UiEvent>()

Channel and SharedFlow themselves are not problematic; you can choose either. The key lies in what delivery semantics we need: whether events are allowed to be dropped, overwritten, replayed, or must wait for the UI to finish processing.

When the producer is the ViewModel and the consumer is the Compose UI, the lifecycles of the two sides are not aligned. At the moment an event is emitted, the UI might not exist. If this business result cannot be lost, immediately reducing it into the UI state is usually easier to guarantee page consistency.

The refresh failure in the Feed example is handled this way. The UI displays the message, and after the display is complete, it notifies the ViewModel to delete the corresponding state:

@Composable
private 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)
        }

        null -> null
    }

    LaunchedEffect(message?.id) {
        if (message != null && text != null) {
            snackbarHostState.showSnackbar(text)
            onMessageShown(message.id)
        }
    }
}

The text ultimately shown to the user is still mapped to string resources by the UI layer; the ViewModel only saves platform-independent message types.

If navigation is directly triggered by an explicit UI click, it remains a UI behavior:

ArticleCard(
    article = article,
    onClick = {
        onArticleClick(article.id)
    },
)

If navigation must wait for business validation, let the ViewModel handle the validation and write the result into the state, then the UI decides how to navigate based on the state. The ViewModel is responsible for business judgment, and the UI executes the specific navigation behavior.

State Restoration, Optimization, and Testing

The page can now stably display and update state. Next, there are three issues often mixed together: how to restore after process recreation, how to judge when recomposition performance problems occur, and how to verify that these state rules actually hold.

Only Save the Minimum Input Needed to Restore the Page

remember allows state to persist during recomposition, but cannot survive Activity recreation.

For data types that support saving, rememberSaveable allows UI element state to survive Activity recreation and system-triggered process death:

var selectedTab by rememberSaveable {
    mutableIntStateOf(0)
}

If a small piece of state has already been hoisted to the ViewModel due to business logic, SavedStateHandle can be used. The filter condition in the Feed example is handled this way:

private val filter: StateFlow<FeedFilter> =
    savedStateHandle.getStateFlow(
        key = FILTER_KEY,
        initialValue = FeedFilter.All,
    )

fun onFilterChanged(filter: FeedFilter) {
    savedStateHandle[FILTER_KEY] = filter
}

The same approach can also be used to save search conditions:

class SearchViewModel(
    private val savedStateHandle: SavedStateHandle,
) : ViewModel() {

    val query: StateFlow<String> =
        savedStateHandle.getStateFlow("query", "Follow RockByte Official Account")

    fun onQueryChanged(value: String) {
        savedStateHandle["query"] = value
    }
}

Both rememberSaveable and SavedStateHandle ultimately use Bundle. Therefore, do not use them to save large object graphs, very long lists, or entire page states.

What truly needs to be saved are usually minimal inputs like query conditions, IDs, Keys, and selected item indices. After process recreation, the page state is regenerated from the data layer based on these inputs.

State restoration itself is also part of the page behavior. For important states, StateRestorationTester can be used to verify whether they can be correctly restored after recreation.

Treat Stability as a Problem to Be Measured

First, ensure the state ownership is correct, the state model is clear, and the data is indeed immutable. As for stability optimization, do it after actual measurement.

Starting from Kotlin 2.0.20, Strong Skipping is enabled by default. When new projects encounter recomposition performance problems, the first step is not to find "how to enable strong skipping mode", but to confirm whether the problem is truly related to stability.

This article can help you better understand Strong Skipping and performance issues.

If, after analysis, it is confirmed that collection stability is indeed causing performance problems, consider:

The Compose compiler can recognize Kotlinx immutable collections, but this library is still marked as Alpha in the official documentation. Whether to use it should be a clear choice based on the project situation; it doesn't need to become a default requirement for all UI states.

@Stable and @Immutable are not decorative annotations either. If marked incorrectly, Compose might still skip recompositions that should have occurred when data changes. If it can be solved through true immutable design, don't use annotations first to override the compiler's judgment.

There is also no need to pursue making every Composable skippable. Stability refactoring without measurement basis can easily turn simple code into hard-to-maintain code.

With Strong Skipping enabled by default, the Compose compiler can already handle many common scenarios. Therefore, stability should usually not be the first stop when troubleshooting performance problems: use tools to confirm the bottleneck first, then decide whether to adjust the state model, collection types, or stability declarations.

Test State Generation and UI Rendering Separately

After the state boundary is clear, testing will naturally split into two parts.

First, test whether the ViewModel generates the correct state:

@Test
fun refreshFailure_keepsContentAndQueuesMessage() = runTest {
    repository.articles.value = listOf(article)
    repository.refreshResult =
        Result.failure(IOException())

    viewModel.uiState
        .filter {
            it.articles.isNotEmpty() &&
                it.pendingMessages.isNotEmpty()
        }
        .test {
            viewModel.refresh()

            val state = awaitItem()

            assertEquals(
                listOf(articleUiModel),
                state.articles,
            )
            assertEquals(
                UserMessage.RefreshFailed,
                state.pendingMessages.first().type,
            )
        }
}

This test focuses on the page rule: after a refresh failure, the cached content still exists, and a pending message is generated. As for how many MutableStateFlow are used internally, it doesn't matter.

Then, pass in an explicit state to test the rendering result of the stateless Screen:

@Test
fun existingContentRemainsVisibleWhileRefreshing() {
    composeRule.setContent {
        FeedScreen(
            state = FeedUiState(
                articles = listOf(articleUiModel),
                initialLoad = InitialLoad.Ready,
                isRefreshing = true,
            ),
            onRefresh = {},
            onFilterChanged = {},
            onArticleClick = {},
        )
    }

    composeRule
        .onNodeWithText(articleUiModel.title)
        .assertIsDisplayed()

    composeRule
        .onNodeWithTag("refreshIndicator")
        .assertIsDisplayed()
}

What is truly worth verifying is the page behavior that users can perceive:

Conclusion

Doing Compose state management well does not necessarily mean choosing the most complex MVI framework, nor does it mean creating a dedicated event sealed class for every state.

What developers truly need to do is first figure out exactly what information the page needs to display at a given moment, then organize this information into a clear state. Keep the state as close to the user as possible; hoist it to the ViewModel when business logic or the data layer needs to participate. The state holder exposes an immutable snapshot externally, the UI collects the state in conjunction with the lifecycle, and clearly passes user actions back.

When refresh, failure, messages, filtering, and restoration can all fall into the same state model, the page is no longer a pile of mutually constraining booleans and events. It ultimately returns to what Compose does best: a UI function whose result is determined by state.

Comments

Top 1 of 2 from juejin.cn, machine-translated. The original thread is authoritative.

似水流年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?