Why Paging 3 Exists: The Memory and UX Problem
PagingSource: Loading Pages from an API
class ArticlePagingSource(
private val api: ArticleApi,
private val query: String?
) : PagingSource<Int, ArticleDto>() {
override suspend fun load(
params: LoadParams<Int>
): LoadResult<Int, ArticleDto> {
val page = params.key ?: 1
val pageSize = params.loadSize
return try {
val response = api.getArticles(
query = query,
page = page,
limit = pageSize
)
LoadResult.Page(
data = response.items,
prevKey = if (page == 1) null else page - 1,
nextKey = if (response.items.size < pageSize) null else page + 1
)
} catch (e: IOException) {
LoadResult.Error(e)
} catch (e: HttpException) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, ArticleDto>): Int? {
// Return the page key closest to the most recently accessed position
return state.anchorPosition?.let { anchor ->
state.closestPageToPosition(anchor)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchor)?.nextKey?.minus(1)
}
}
}
// API interface
interface ArticleApi {
@GET("articles")
suspend fun getArticles(
@Query("q") query: String?,
@Query("page") page: Int,
@Query("limit") limit: Int
): PaginatedResponse<ArticleDto>
}
@Serializable
data class PaginatedResponse<T>(
val items: List<T>,
val page: Int,
val totalPages: Int,
@SerialName("has_more") val hasMore: Boolean
)RemoteMediator: Offline-First Pagination
@OptIn(ExperimentalPagingApi::class)
class ArticleRemoteMediator(
private val api: ArticleApi,
private val db: AppDatabase
) : RemoteMediator<Int, ArticleEntity>() {
private val articleDao = db.articleDao()
private val remoteKeyDao = db.remoteKeyDao()
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, ArticleEntity>
): MediatorResult {
val page = when (loadType) {
LoadType.REFRESH -> 1
LoadType.PREPEND -> return MediatorResult.Success(
endOfPaginationReached = true // No prepend for this API
)
LoadType.APPEND -> {
val remoteKey = remoteKeyDao.getKey("articles")
?: return MediatorResult.Success(endOfPaginationReached = true)
remoteKey.nextPage
?: return MediatorResult.Success(endOfPaginationReached = true)
}
}
return try {
val response = api.getArticles(
query = null,
page = page,
limit = state.config.pageSize
)
val endReached = response.items.size < state.config.pageSize
db.withTransaction {
if (loadType == LoadType.REFRESH) {
articleDao.clearAll()
remoteKeyDao.clearAll()
}
articleDao.insertAll(response.items.map { it.toEntity() })
remoteKeyDao.insert(
RemoteKey(
id = "articles",
nextPage = if (endReached) null else page + 1
)
)
}
MediatorResult.Success(endOfPaginationReached = endReached)
} catch (e: IOException) {
MediatorResult.Error(e)
} catch (e: HttpException) {
MediatorResult.Error(e)
}
}
}
// Room DAO with PagingSource factory
@Dao
interface ArticleDao {
@Query("SELECT * FROM articles ORDER BY published_at DESC")
fun pagingSource(): PagingSource<Int, ArticleEntity>
@Upsert
suspend fun insertAll(articles: List<ArticleEntity>)
@Query("DELETE FROM articles")
suspend fun clearAll()
}
// Remote key tracking for pagination state
@Entity(tableName = "remote_keys")
data class RemoteKey(
@PrimaryKey val id: String,
val nextPage: Int?
)Wiring It Together: Pager and ViewModel
@HiltViewModel
class ArticleListViewModel @Inject constructor(
private val api: ArticleApi,
private val db: AppDatabase
) : ViewModel() {
// Offline-first: RemoteMediator + Room PagingSource
@OptIn(ExperimentalPagingApi::class)
val articles: Flow<PagingData<ArticleEntity>> = Pager(
config = PagingConfig(
pageSize = 20,
prefetchDistance = 5, // Start loading 5 items before the end
initialLoadSize = 40, // First page loads 2x for fast initial render
maxSize = 200, // Evict pages beyond 200 items
enablePlaceholders = false
),
remoteMediator = ArticleRemoteMediator(api, db),
pagingSourceFactory = { db.articleDao().pagingSource() }
).flow.cachedIn(viewModelScope)
// Network-only (simpler, no offline support)
fun searchArticles(query: String): Flow<PagingData<ArticleDto>> = Pager(
config = PagingConfig(pageSize = 20),
pagingSourceFactory = { ArticlePagingSource(api, query) }
).flow.cachedIn(viewModelScope)
}Compose Integration: LazyColumn with Loading States
@Composable
fun ArticleListScreen(
viewModel: ArticleListViewModel = hiltViewModel()
) {
val articles = viewModel.articles.collectAsLazyPagingItems()
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Initial loading state
when (articles.loadState.refresh) {
is LoadState.Loading -> {
item {
Box(Modifier.fillParentMaxSize(), Alignment.Center) {
CircularProgressIndicator()
}
}
}
is LoadState.Error -> {
val error = articles.loadState.refresh as LoadState.Error
item {
ErrorRetryCard(
message = error.error.localizedMessage ?: "Failed to load",
onRetry = { articles.retry() }
)
}
}
is LoadState.NotLoading -> {
if (articles.itemCount == 0) {
item {
EmptyState(message = "No articles found")
}
}
}
}
// Article items
items(
count = articles.itemCount,
key = articles.itemKey { it.id }
) { index ->
val article = articles[index]
if (article != null) {
ArticleCard(
article = article,
modifier = Modifier.animateItem()
)
} else {
ArticlePlaceholder() // Shimmer placeholder
}
}
// Append loading / error at bottom
when (articles.loadState.append) {
is LoadState.Loading -> {
item {
Box(Modifier.fillMaxWidth().padding(16.dp), Alignment.Center) {
CircularProgressIndicator(Modifier.size(24.dp))
}
}
}
is LoadState.Error -> {
item {
TextButton(
onClick = { articles.retry() },
modifier = Modifier.fillMaxWidth()
) {
Text("Load more failed. Tap to retry.")
}
}
}
else -> {}
}
}
}
@Composable
private fun ErrorRetryCard(message: String, onRetry: () -> Unit) {
Column(
modifier = Modifier.fillMaxWidth().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(message, style = MaterialTheme.typography.bodyLarge)
Button(onClick = onRetry) { Text("Retry") }
}
}Key Takeaways
- 1Paging 3 loads data incrementally and evicts off-screen pages, preventing memory exhaustion on large datasets.
- 2PagingSource handles single-source pagination (API or database). RemoteMediator coordinates network + Room for offline-first infinite scrolling.
- 3PagingConfig.prefetchDistance controls how early the next page loads -- set it to 3-5 items for seamless scrolling without visible loading pauses.
- 4collectAsLazyPagingItems() bridges PagingData into Compose with first-class loading, error, and empty states via loadState properties.
- 5Always use cachedIn(viewModelScope) to survive configuration changes without re-fetching data from the beginning.
- 6RemoteMediator.load() runs inside a Room transaction for atomic database updates, preventing partial page writes on network failures.
Frequently Asked
Paging 3 vs manual pagination?
Paging 3 handles caching, deduplication, and error recovery automatically. Manual pagination requires implementing all edge cases. Use Paging 3 for any list with more than 100 items.
How do I handle item updates?
Paging 3 invalidates and refreshes automatically when database changes. Use Flow to observe data changes. RemoteMediator handles server-side updates.
Ready to architect your next Android app?
ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.