Loading, Empty, Error, and Retry States in Compose Lists

A list screen needs more than rows: it must deliberately render loading, empty, error, and retry states. Model those states in the screen state holder, then let the composable choose one clear presentation. With Paging, treat the initial refresh state differently from an append state at the bottom of an already visible list.

Android’s Paging load-state guide distinguishes full-content visibility states from loading or error items inserted in a LazyColumn. The Compose list guide also recommends placeholders whose size is close to the eventual content.

Model the screen, not just the list

sealed interface ArticleListUiState {
    data object Loading : ArticleListUiState
    data object Empty : ArticleListUiState
    data class Error(val message: String) : ArticleListUiState
    data class Content(val articles: List<ArticleUi>) : ArticleListUiState
}

The ViewModel owns the request and retry decision. The screen renders state and forwards an onRetry event; it does not decide network policy in a composable.

Render one primary state clearly

@Composable
fun ArticleScreen(
    state: ArticleListUiState,
    onRetry: () -> Unit
) {
    when (state) {
        ArticleListUiState.Loading -> FullScreenLoading()
        ArticleListUiState.Empty -> EmptyArticles()
        is ArticleListUiState.Error -> ErrorState(
            message = state.message,
            onRetry = onRetry
        )
        is ArticleListUiState.Content -> LazyColumn {
            items(state.articles, key = { it.id }) { article ->
                ArticleRow(article = article, onClick = {})
            }
        }
    }
}

An empty state is a successful result with no content; it is not an error. Explain what is empty and, when useful, provide a relevant next action such as clearing a filter.

Paging refresh and append states

For Paging 3, refresh describes the initial or replacement load. append describes fetching more after existing content. A full-screen loading state makes sense for refresh; a compact footer is better for append.

when (val refresh = pagingItems.loadState.refresh) {
    is LoadState.Loading -> FullScreenLoading()
    is LoadState.Error -> ErrorState(
        message = refresh.error.message ?: "Try again.",
        onRetry = pagingItems::retry
    )
    is LoadState.NotLoading -> Unit
}

LazyColumn {
    items(pagingItems.itemCount, key = pagingItems.itemKey { it.id }) { index ->
        pagingItems[index]?.let(::ArticleRow)
    }
    item {
        when (pagingItems.loadState.append) {
            is LoadState.Loading -> LoadingRow()
            is LoadState.Error -> RetryRow(onRetry = pagingItems::retry)
            is LoadState.NotLoading -> Unit
        }
    }
}

Do not show a full-screen spinner when the user can still read loaded rows. Keep retry close to the failed append when that is the failed operation.

Practical details

  • Keep loading placeholders close to the final row size to reduce layout jumps.
  • Make retry actionable and keep technical error details out of user-facing copy.
  • Preserve stable keys for loaded rows; see Lazy list keys.
  • Test offline, a successful empty response, initial failure, append failure, and retry.

FAQ

Is an empty result an error?

No. It is normally a successful state with no items and should explain that condition clearly.

Where should append loading appear?

As a dedicated item at the end of the LazyColumn, while already loaded rows remain visible.

What should retry do?

Forward the event to the state owner; for Paging, LazyPagingItems.retry() retries failed load requests.