Introduction
Fetching and displaying large datasets in Android apps has always been a challenge: do you load everything at once and risk memory exhaustion, or implement pagination manually and duplicate the same boilerplate across every screen? Paging 3 with Compose: a practical Android & Kotlin guide teaches you how to handle pagination elegantly, letting you load data page by page while your Compose UI stays responsive and memory-efficient. We'll walk through each component—from the data source to the UI layer—and show you how to build real-world pagination that handles both online and offline scenarios.
PagingSource — Loads pages from the network or database
The foundation of Paging 3 is PagingSource, an abstract class that defines how to load a single page of data. When you extend it, you implement two methods: load() to fetch a page and getRefreshKey() to determine where to start when the data is refreshed.
Consider a real-world scenario: you're building an e-commerce app that loads products from an API. Each request returns 20 items and accepts a page number. Here's how you'd create a PagingSource:
class ProductPagingSource(
private val apiService: ApiService,
private val category: String
) : PagingSource<Int, Product>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Product> {
return try {
val page = params.key ?: 1
val response = apiService.getProducts(
category = category,
page = page,
pageSize = params.loadSize
)
LoadResult.Page(
data = response.products,
prevKey = if (page == 1) null else page - 1,
nextKey = if (response.products.isEmpty()) null else page + 1
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, Product>): Int? {
return state.anchorPosition?.let { position ->
state.closestPageToPosition(position)?.nextKey?.minus(1)
}
}
}The LoadParams object tells you what page to fetch and how many items per page. Notice that getRefreshKey() returns the page number to use when the user pulls to refresh—this ensures we don't restart from page 1 and lose the user's scroll position unnecessarily.
LoadResult is a sealed class with two variants: LoadResult.Page for success and LoadResult.Error for failures. Paging 3 automatically retries failed pages, so your network errors don't permanently break the list.
Pager — Exposes Flow<PagingData> from the ViewModel
Once you have a PagingSource, you wrap it in a Pager configuration object. The Pager manages the lifecycle of your PagingSource instances (yes, it creates new ones on refresh) and exposes a Flow<PagingData<T>> that your ViewModel or repository can expose to the UI layer.
In your ViewModel or repository, set up the pager like this:
class ProductRepository(private val apiService: ApiService) {
fun getProductsPaged(category: String): Flow<PagingData<Product>> {
return Pager(
config = PagingConfig(
pageSize = 20,
enablePlaceholders = false,
initialLoadSize = 40
),
pagingSourceFactory = {
ProductPagingSource(apiService, category)
}
).flow
}
}
class ProductViewModel(repository: ProductRepository) : ViewModel() {
private val category = MutableStateFlow("electronics")
val productsFlow: Flow<PagingData<Product>> = category
.flatMapLatest { cat ->
repository.getProductsPaged(cat)
}
.stateIn(viewModelScope, SharingStarted.Lazily, PagingData.empty())
}PagingConfig controls pagination behavior: pageSize is the number of items per page, enablePlaceholders lets Paging show placeholder items (useful for skeleton loaders but adds complexity), and initialLoadSize sets how many items to fetch on the first load (often 2x pageSize).
The flatMapLatest pattern ensures that when the user changes categories, we cancel the old pagination stream and start fresh. This is essential for multi-filter scenarios common in real production apps.
collectAsLazyPagingItems — Connect to LazyColumn
With the data flowing from the ViewModel, now you bind it to your Compose UI using collectAsLazyPagingItems(). This extension function (from the Paging 3 Compose library) converts Flow<PagingData<T>> into a LazyPagingItems<T> that you can iterate over in a LazyColumn.
Here's a practical example showing how to display products with proper loading and error states:
@Composable
fun ProductListScreen(viewModel: ProductViewModel) {
val productsFlow = viewModel.productsFlow
val lazyPagingItems = productsFlow.collectAsLazyPagingItems()
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(
count = lazyPagingItems.itemCount,
key = { index -> lazyPagingItems[index]?.id ?: index }
) { index ->
val product = lazyPagingItems[index]
if (product != null) {
ProductCard(product = product)
} else {
// Placeholder while loading
ProductCardPlaceholder()
}
}
// Loading indicator at the end
when (lazyPagingItems.loadState.append) {
is LoadState.Loading -> {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
is LoadState.Error -> {
item {
Text(
text = "Failed to load more products. Tap to retry.",
modifier = Modifier
.fillMaxWidth()
.clickable {
lazyPagingItems.retry()
}
.padding(16.dp)
)
}
}
LoadState.NotLoading(endOfPaginationReached = true) -> {
item {
Text(
text = "No more products",
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
textAlign = TextAlign.Center
)
}
}
else -> {}
}
// Refresh indicator at the top
when (lazyPagingItems.loadState.refresh) {
is LoadState.Loading -> {
item {
Box(modifier = Modifier.fillMaxWidth().height(50.dp)) {
CircularProgressIndicator()
}
}
}
is LoadState.Error -> {
item {
Text("Refresh failed")
}
}
else -> {}
}
}
}LazyPagingItems has three loadState properties: refresh (top-level load), append (loading more), and prepend (less common). You can inspect these to show the appropriate UI. The retry() function lets you retry failed pages, which you typically wire to an error message or button.
RemoteMediator — Cache pages in Room for offline support
In production apps, users expect offline support. RemoteMediator is the advanced pattern that coordinates between your network data source and a local Room database, acting as a fallback when the network is unavailable.
The pattern works like this: when Paging 3 needs a page, RemoteMediator.load() fetches from the network and caches it in Room. On subsequent loads or if the network fails, Paging reads from the database instead.
First, set up a Room DAO that can query paginated data and clear old pages:
@Dao
interface ProductDao {
@Query("SELECT * FROM products WHERE category = :category ORDER BY id DESC LIMIT :pageSize OFFSET :offset")
suspend fun getProductsByCategory(
category: String,
offset: Int,
pageSize: Int
): List<Product>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertProducts(products: List<Product>)
@Query("DELETE FROM products WHERE category = :category")
suspend fun clearProducts(category: String)
}Next, implement RemoteMediator:
class ProductRemoteMediator(
private val category: String,
private val apiService: ApiService,
private val database: AppDatabase
) : RemoteMediator<Int, Product>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, Product>
): MediatorResult {
return try {
val loadKey = when (loadType) {
LoadType.REFRESH -> 1
LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
LoadType.APPEND -> {
val lastItem = state.lastItemOrNull() ?: return MediatorResult.Success(endOfPaginationReached = true)
(lastItem.id / state.config.pageSize) + 1
}
}
val response = apiService.getProducts(
category = category,
page = loadKey,
pageSize = state.config.pageSize
)
database.withTransaction {
if (loadType == LoadType.REFRESH) {
database.productDao().clearProducts(category)
}
database.productDao().insertProducts(response.products)
}
MediatorResult.Success(endOfPaginationReached = response.products.isEmpty())
} catch (e: Exception) {
MediatorResult.Error(e)
}
}
}Then, in your repository, combine the network source with the database:
class ProductRepository(
private val apiService: ApiService,
private val database: AppDatabase
) {
fun getProductsPagedWithCache(category: String): Flow<PagingData<Product>> {
return Pager(
config = PagingConfig(pageSize = 20, enablePlaceholders = false),
remoteMediator = ProductRemoteMediator(category, apiService, database),
pagingSourceFactory = {
database.productDao().getProductsByCategory(category)
}
).flow
}
}Notice that the pagingSourceFactory now creates a PagingSource from the database query, not the network. The RemoteMediator intercepts requests, fetches from the network, and populates the database before Paging reads from it. If the network fails, Paging falls back to whatever's already cached.
Common mistakes
- Forgetting
stateIn()in the ViewModel. Without it, every Compose recomposition triggers a new collection, re-subscribing to the flow and restarting pagination. Always usestateIn(viewModelScope, SharingStarted.Lazily, PagingData.empty()). - Setting
enablePlaceholders = truewithout thinking it through. Placeholders require Paging to know the total count upfront, which not all APIs provide. The defaultfalseis safer for most apps. - Not handling
loadStateproperly. If you don't show loading indicators or error messages, users won't know what's happening. Always inspect and displayrefreshandappendstates. - Infinite refresh loops with
RemoteMediator. Make suregetRefreshKey()in yourPagingSourcereturns a sensible value, and that yourRemoteMediator.load()respects load types correctly. - Ignoring the difference between
keyandidinLazyColumn.items(). Use stable, unique keys (like database IDs) to avoid layout thrashing when items are updated.
Key takeaways
PagingSourceabstracts data loading; implementload()to fetch a page andgetRefreshKey()for refresh logic.Pagerwraps your source and exposesFlow<PagingData<T>>to the ViewModel; useflatMapLatestto handle filter changes.collectAsLazyPagingItems()binds flow data to Compose; always inspect and displayloadStatefor UX clarity.RemoteMediatorcoordinates network and database layers; implementLoadTypehandling and usedatabase.withTransactionfor consistency.- Stable keys, proper state management, and careful error handling are non-negotiable in production pagination.






Comments
Loading comments…