Paging 3.5.0 hit stable on May 6, 2026, and it deserves more attention than it got. The headline — a new Flow operator and some manual load triggers — sounds like bookkeeping. In practice, it closes the most irritating gap between the Paging library and modern Compose architecture: the fact that paging state and the rest of your UI state have always lived in different worlds, with different APIs, different collection patterns, and no clean way to combine them.
If you've ever tried to merge a LazyPagingItems<T> with another StateFlow in a combine block, you know what I mean. You can't. The two systems speak different languages. Paging 3.5 fixes that — and adds manual load triggers that unblock a class of feed patterns I've wanted for years.
The Problem With LazyPagingItems
To understand why 3.5 matters, you need to sit with the old API for a moment. The existing pattern — pager.flow.collectAsLazyPagingItems() — gives you a LazyPagingItems<T> object that drives a LazyColumn. It works, and for simple cases it's fine:
val items = viewModel.pagingFlow.collectAsLazyPagingItems()
LazyColumn {
items(items) { item ->
if (item != null) NewsRow(item)
}
when {
items.loadState.refresh is LoadState.Loading -> {
item { CircularProgressIndicator() }
}
items.loadState.append is LoadState.Loading -> {
item { MoreLoadingRow() }
}
items.loadState.append is LoadState.Error -> {
item { RetryButton(onClick = { items.retry() }) }
}
}
}
The friction starts the moment your screen has more than one source of state. Want to combine the paged items with a selected filter chip, a network error banner, or a "new items available" badge? You can't put LazyPagingItems into a combine() block. It's not a Flow — it's a specialized Compose state object with its own observation lifecycle. So you end up threading the filter state separately, rebuilding the item list in the composable itself, or storing an intermediate list in the ViewModel via a collectLatest loop that defeats the point of using Paging in the first place.
I hit this wall hard while building the news feed in Samachar. The screen needed paged articles, a selected category filter, a "breaking news" banner from a separate WebSocket stream, and a pull-to-refresh state. Every time I combined these, something was awkward. The paging state sat outside the normal UiState sealed class because I couldn't box it alongside the rest of the state.
asItemSnapshotListFlow: Paging as a Real Flow
asItemSnapshotListFlow() converts your Flow<PagingData<T>> into a Flow<ItemSnapshotList<T>>. An ItemSnapshotList is essentially a snapshot of the currently loaded items as a plain list — nulls where placeholders sit, actual items everywhere else. Because it's a standard Flow, you can do everything you'd normally do with a Flow: combine(), stateIn(), cache it, map it, filter it, expose it as part of a UiState data class.
// In ViewModel
private val pager = Pager(PagingConfig(pageSize = 20)) {
NewsPagingSource(newsApi, selectedCategory.value)
}
val uiState: StateFlow<NewsUiState> = combine(
pager.flow.asItemSnapshotListFlow(),
selectedCategoryFlow,
breakingNewsFlow
) { snapshot, category, breaking ->
NewsUiState(
articles = snapshot.items.filterNotNull(),
placeholderCount = snapshot.placeholdersAfter,
selectedCategory = category,
breakingNews = breaking
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), NewsUiState())
// In Composable
val state by viewModel.uiState.collectAsStateWithLifecycle()
LazyColumn {
items(state.articles) { article -> NewsRow(article) }
if (state.placeholderCount > 0) {
items(state.placeholderCount) { LoadingPlaceholderRow() }
}
}
The ViewModel now owns a single UiState that contains everything the screen needs. The composable collects one flow, not three. Testing the ViewModel means testing one state machine instead of verifying how Paging state and other state interact at the Compose layer.
API note: this was originally named asState() in the 3.5.0-alpha01 release (March 2026). It was renamed to asItemSnapshotListFlow() in beta01 (April 2026) to make its semantics explicit. The stable release uses the asItemSnapshotListFlow name — if you prototyped with the alpha, update your call sites.
ItemSnapshotList also exposes placeholdersBefore and placeholdersAfter — the count of items the Paging library knows exist but hasn't loaded yet. If your data source reports a known total, you can render placeholder rows during initial load or while approaching the list end, without hardcoding any of that logic in the composable.
Manual Load Triggers: append(), prepend(), refresh(), retry()
The second part of 3.5.0 is four new suspending functions on Pager: append(), prepend(), refresh(), and retry(). These only apply when you're using asItemSnapshotListFlow() — the old LazyPagingItems API has its own built-in equivalents.
Before 3.5, the only way to trigger a page load was to scroll close enough to the edge that the library detected it. This works for the standard infinite scroll case but breaks down in a surprising number of real patterns:
- Chat feeds — you want to load history upward when the user scrolls near the top, and new messages downward when they arrive, but you're not always at the edge
- "Load more" buttons — the product team wants an explicit button rather than auto-load; previously you faked this with a scroll trigger that felt wrong
- Pre-loading on intent — the user taps a "see more" chip on the home screen; you want to pre-warm the next page before they navigate to the list
- Network-driven refresh — a push notification arrives, you want to refresh without the user pulling down
// Trigger load more from a button
Button(
onClick = { coroutineScope.launch { pager.append() } }
) {
Text("Load more")
}
// Bidirectional paging in a chat screen
LazyColumn(reverseLayout = true) {
item {
LaunchedEffect(Unit) { pager.prepend() }
}
items(state.messages) { message -> MessageRow(message) }
item {
LaunchedEffect(Unit) { pager.append() }
}
}
// Pull-to-refresh with SwipeRefresh
SwipeRefresh(
state = rememberSwipeRefreshState(state.isRefreshing),
onRefresh = { viewModel.refresh() }
) {
LazyColumn { /* items */ }
}
// In ViewModel
fun refresh() {
viewModelScope.launch { pager.refresh() }
}
fun retryFailed() {
viewModelScope.launch { pager.retry() }
}
The behavior of refresh() here matches what LazyPagingItems.refresh() does — it invalidates the current PagingSource and starts over from the first page. retry() re-attempts only the last failed load, which is what you want for a network error retry button at the end of the list.
For the HailUp video feed, this pattern would finally let me trigger the next-page preload from the business logic when a user taps the "up next" overlay — before they've scrolled to the edge of the loaded items. Previously I was faking this by maintaining a separate preload counter and making direct API calls alongside Paging, which defeated the point of using the library at all.
Where the Old API Still Makes Sense
I want to be clear: collectAsLazyPagingItems() is not deprecated. The two APIs coexist and serve different cases.
Stick with LazyPagingItems when:
- Your paged data is the entire screen state — no merging with other flows needed
- You want the scroll-triggered loading behavior with zero configuration
- You need the
itemKeyanditemContentTypehelpers thatitems(lazyPagingItems)provides automatically - You're working with an existing codebase where refactoring to
asItemSnapshotListFlowdoesn't buy you anything
Reach for asItemSnapshotListFlow when:
- You need to combine paging state with other flows in a single
UiState - You need manual load control that isn't driven by scroll position
- Your ViewModel already owns a
StateFlow<UiState>and you want paging to live inside it rather than alongside it - You're building bidirectional feeds, chat UIs, or "load more" button patterns
Upgrading to 3.5.0
Paging 3.5.0 is a stable release with no breaking changes if you're coming from 3.3.x or 3.4.x. The only breaking change in this cycle was the rename from asState to asItemSnapshotListFlow, which happened in beta01 — so if you're coming from stable to stable you won't hit it.
dependencies {
val pagingVersion = "3.5.0"
implementation("androidx.paging:paging-runtime:$pagingVersion")
// Compose integration
implementation("androidx.paging:paging-compose:$pagingVersion")
// Testing
testImplementation("androidx.paging:paging-common:$pagingVersion")
}
The paging-compose artifact is separate from paging-runtime — make sure you have both if you're using Compose. If you're coming from an older Compose integration that used the combined artifact approach, check that your dependency declarations are pointing to the right artifact names.
After upgrading, nothing changes for your existing collectAsLazyPagingItems() usage. You can adopt asItemSnapshotListFlow() screen by screen, wherever the pattern solves a real problem you're currently fighting around.
Performance considerations
One thing worth knowing: asItemSnapshotListFlow() emits a new ItemSnapshotList on every page load, placeholder update, or item change. If your combine block does non-trivial work, make sure you're not doing it on every emission. Use distinctUntilChanged() or structure your flow so that expensive transforms only run when the data they depend on actually changes. The Paging library tries to coalesce rapid updates into single emissions, but don't rely on that as a performance substitute for clean flow design.
The Bigger Picture
Paging 3.5.0 is not a flashy release. There's no new layout primitive, no API that changes how you think about navigation, no Compose-first rewrite. But it closes a real architectural gap that's existed since Paging 3 launched: the gap between "paging state" and "the rest of your app state."
Most of the LazyPagingItems workarounds I've seen in real codebases — parallel StateFlows holding a cached copy of loaded items, custom wrappers that replicate parts of the Paging machinery, separate network calls alongside Paging for the first page — exist because the old API forced you to manage paging state outside your normal state container. asItemSnapshotListFlow() removes that pressure. Your ViewModel can own one state object, your composable can collect one flow, and the complexity that used to leak into the UI layer can stay in the ViewModel where it belongs.
That's worth a dependency bump.
No comments yet. Be the first to leave one!