Paging 3 with Jetpack Compose

AndroidJetpack ComposePagingArchitecture
Share on LinkedIn

Paging 3 with Compose is the standard pattern for infinite scroll lists — orders, messages, search results, feeds. The library handles loading pages in the background, deduplicating, and presenting a seamless scroll experience. What trips teams up is the LoadState handling (three different load states, each with loading/error/success), the ViewModel caching setup, and knowing when you need RemoteMediator for offline-first. I've built paging into enough list screens to know the 80% case takes an hour; the remaining 20% (offline cache, header/footer, jump-to-top) is where the docs get sparse.

Basic setup

class OrdersViewModel @Inject constructor(
    private val repository: OrderRepository
) : ViewModel() {
    val orders: Flow<PagingData<Order>> = repository.getOrdersPaged()
        .cachedIn(viewModelScope)
}
// Repository
fun getOrdersPaged(): Flow<PagingData<Order>> {
    return Pager(
        config = PagingConfig(pageSize = 20, enablePlaceholders = false),
        pagingSourceFactory = { OrderPagingSource(api) }
    ).flow
}
// PagingSource
class OrderPagingSource(private val api: OrderApi) : PagingSource<Int, Order>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Order> {
        return try {
            val page = params.key ?: 0
            val response = api.getOrders(page = page, size = params.loadSize)
            LoadResult.Page(
                data = response.items,
                prevKey = if (page == 0) null else page - 1,
                nextKey = if (response.items.isEmpty()) null else page + 1
            )
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }

    override fun getRefreshKey(state: PagingState<Int, Order>): Int? {
        return state.anchorPosition?.let { anchor ->
            state.closestPageToPosition(anchor)?.prevKey?.plus(1)
                ?: state.closestPageToPosition(anchor)?.nextKey?.minus(1)
        }
    }
}

Compose UI

@Composable
fun OrdersScreen(viewModel: OrdersViewModel = hiltViewModel()) {
    val lazyOrders = viewModel.orders.collectAsLazyPagingItems()

    LazyColumn {
        items(
            count = lazyOrders.itemCount,
            key = lazyOrders.itemKey { it.id },
        ) { index ->
            val order = lazyOrders[index]
            if (order != null) {
                OrderRow(order = order)
            } else {
                OrderRowPlaceholder()
            }
        }

        // Loading/error footer
        when (val state = lazyOrders.loadState.append) {
            is LoadState.Loading -> item { LoadingFooter() }
            is LoadState.Error -> item {
                ErrorFooter(onRetry = { lazyOrders.retry() })
            }
            else -> {}
        }
    }

    // Full-screen refresh state
    when (val state = lazyOrders.loadState.refresh) {
        is LoadState.Loading -> LoadingScreen()
        is LoadState.Error -> ErrorScreen(onRetry = { lazyOrders.retry() })
        else -> {}
    }
}

Three load states to handle:

Pull-to-refresh

val lazyOrders = viewModel.orders.collectAsLazyPagingItems()

PullToRefreshBox(
    isRefreshing = lazyOrders.loadState.refresh is LoadState.Loading,
    onRefresh = { lazyOrders.refresh() },
) {
    LazyColumn { /* items */ }
}

Empty state

if (lazyOrders.loadState.refresh is LoadState.NotLoading && lazyOrders.itemCount == 0) {
    EmptyOrdersScreen()
}

Check both conditions — NotLoading alone fires before the first load completes.

Offline-first with RemoteMediator

For cached lists that work offline, add a RemoteMediator:

Pager(
    config = PagingConfig(pageSize = 20),
    remoteMediator = OrderRemoteMediator(db, api),
    pagingSourceFactory = { db.orderDao.pagingSource() }
).flow

PagingSource reads from Room; RemoteMediator fetches from network and caches.

Common pitfalls

Forgetting cachedIn(viewModelScope). Without it, every recomposition restarts paging from page 0.

Not setting key in items(). Unkeyed lists cause recomposition glitches on insert/delete.

Using enablePlaceholders = true without null checks. Placeholders return null items — handle with placeholder UI or disable placeholders.

Blocking the main thread in PagingSource.load(). All loading is already suspend — but don't wrap blocking calls without withContext(Dispatchers.IO).

Paging with search and filters

When the list has search or filter state, invalidate PagingSource on filter change:

class OrderViewModel : ViewModel() {
    private val _searchQuery = MutableStateFlow("")
    val searchQuery = _searchQuery.asStateFlow()

    @OptIn(ExperimentalCoroutinesApi::class)
    val orders: Flow<PagingData<Order>> = searchQuery
        .debounce(300)
        .distinctUntilChanged()
        .flatMapLatest { query ->
            Pager(
                config = PagingConfig(pageSize = 20, enablePlaceholders = false),
                pagingSourceFactory = { OrderPagingSource(api, query) }
            ).flow
        }
        .cachedIn(viewModelScope)

    fun onSearch(query: String) { _searchQuery.value = query }
}

flatMapLatest cancels the previous PagingSource when search changes — no stale results from old query.

Header and footer items in paged lists

Use item and items together in LazyColumn:

LazyColumn {
    item { SearchBar(onQueryChange = viewModel::onSearch) }
    items(
        count = lazyPagingItems.itemCount,
        key = lazyPagingItems.itemKey { it.id }
    ) { index ->
        lazyPagingItems[index]?.let { order ->
            OrderRow(order)
        }
    }
    if (lazyPagingItems.loadState.append is LoadState.Loading) {
        item { CircularProgressIndicator(Modifier.fillMaxWidth()) }
    }
}

Header items (search bar, filters) sit outside the paged items — they don't reload on page fetch.

Testing PagingSource

Test paging logic without Compose:

class OrderPagingSourceTest {
    @Test
    fun loadFirstPage_returnsCorrectItems() = runTest {
        val source = OrderPagingSource(fakeApi, query = "")
        val result = source.load(PagingSource.LoadParams.Refresh(key = null, loadSize = 20, placeholdersEnabled = false))
        assertThat(result).isInstanceOf(PagingSource.LoadResult.Page::class.java)
        val page = result as PagingSource.LoadResult.Page
        assertThat(page.data).hasSize(20)
        assertThat(page.nextKey).isEqualTo(2)
    }
}

Test edge cases: empty result, error response, last page (nextKey = null).

Failure modes

Production checklist

Common production mistakes

Teams get paging3 compose wrong in predictable ways:

Shipping paging3 compose on Android fails quietly when you test only on flagship devices, skip process-death scenarios, or assume minSdk behavior matches latest API docs. Emulator-only validation misses OEM-specific battery optimizations and background execution limits.

Resources

Frequently asked questions

How do you use Paging 3 with Jetpack Compose?

Collect a Flow<PagingData<T>> in your ViewModel using cachedIn(viewModelScope). In Compose, collect it with collectAsLazyPagingItems() and pass the result to LazyColumn's items() extension. Handle LoadState for loading, error, and empty states via lazyPagingItems.loadState.

What is the difference between Paging 2 and Paging 3?

Paging 3 uses Flow instead of LiveData, has built-in Kotlin coroutine support, separates concerns with PagingSource and RemoteMediator, and provides first-class Compose integration via LazyPagingItems. Paging 2's PagedListAdapter is replaced by LazyPagingItems in Compose or PagingDataAdapter in Views.

How do you handle errors in Paging 3 with Compose?

Check lazyPagingItems.loadState.refresh, .prepend, and .append for LoadState.Error. Display error UI and offer retry via lazyPagingItems.retry(). For item-level errors, use PagingSource.LoadResult.Error which shows an error state for that page while keeping loaded items visible.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →