Paging 3 with Jetpack Compose: A Practical LazyColumn Setup

Paging 3 lets a Compose screen display a large or remote collection incrementally instead of loading every row upfront. Expose Flow<PagingData<T>> from the presentation layer, collect it with collectAsLazyPagingItems(), render its indexed items in LazyColumn, and handle loading and failure with loadState.
Android’s lazy lists guide recommends Paging for large datasets and documents Compose support through androidx.paging:paging-compose. The Paging Compose API provides itemKey and itemContentType helpers for lazy layouts.
Display paged items
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
@Composable
fun MessageList(viewModel: MessagesViewModel) {
val messages = viewModel.messages.collectAsLazyPagingItems()
LazyColumn {
items(
count = messages.itemCount,
key = messages.itemKey { message -> message.id }
) { index ->
val message = messages[index]
if (message != null) {
MessageRow(message = message)
} else {
MessagePlaceholder()
}
}
}
}LazyPagingItems may return null when placeholders are enabled, so the row must handle that case. The official documentation warns that placeholders used with RemoteMediator must have realistic dimensions: tiny or missing placeholders can cause the mediator to keep fetching because the viewport never appears full.
Keep identity and layout types explicit
Use itemKey for a stable row ID and itemContentType when paged content has distinct reusable shapes.
import androidx.paging.compose.itemContentType
items(
count = messages.itemCount,
key = messages.itemKey { it.id },
contentType = messages.itemContentType { message -> message.kind }
) { index ->
val message = messages[index]
if (message == null) MessagePlaceholder() else MessageRow(message)
}Keys preserve identity; content types describe compatible row structures. Read lazy list keys and contentType in mixed lists for the distinction.
Render refresh and append states
loadState distinguishes the initial refresh from loading more content at the end. Keep retry behavior explicit and let the ViewModel own any domain policy.
import androidx.paging.LoadState
when (val refresh = messages.loadState.refresh) {
is LoadState.Loading -> FullScreenLoading()
is LoadState.Error -> ErrorScreen(
message = refresh.error.message,
onRetry = messages::retry
)
is LoadState.NotLoading -> Unit
}
LazyColumn {
// paged rows
item {
when (val append = messages.loadState.append) {
is LoadState.Loading -> LoadingRow()
is LoadState.Error -> RetryRow(
onRetry = messages::retry
)
is LoadState.NotLoading -> Unit
}
}
}Do not show a full-screen spinner for every append. Initial loading, an empty successful result, append loading, and an append error are different user situations.
Common pitfalls
- Creating a new
Pagerin the composable instead of exposing a stable flow from the screen layer. - Omitting
itemKeyfor data that can refresh or reorder. - Assuming
itemCountmeans every item is already loaded. - Ignoring
nullplaceholders. - Using placeholder rows with unrealistic height when a
RemoteMediatoris fetching.
Test refresh, append, retry, empty results, rotation, and slow network conditions. For the surrounding UI states, see the next companion topic: loading, empty, error, and retry list states.
FAQ
Does Paging 3 work with Compose?
Yes. Compose integration is provided by androidx.paging:paging-compose for Paging 3.0 and later.
Where should collectAsLazyPagingItems() run?
In the composable that renders the paged list. The Paging documentation notes that collection runs in the composition scope.
Should every paged list use placeholders?
Not necessarily. Handle null entries when placeholders are enabled, and choose realistic placeholder dimensions when using a remote mediator.