On this page
A hand-rolled infinite scroll has to track the current page, avoid duplicate requests while scrolling fast, show a footer spinner, handle errors with retry, reset on refresh, and survive rotation. Paging 3 handles all of this. You implement one function that loads a page.
The pieces
Setup
dependencies {
implementation("androidx.paging:paging-runtime:<paging-version>")
implementation("androidx.paging:paging-compose:<paging-version>")
testImplementation("androidx.paging:paging-testing:<paging-version>")
}1. PagingSource
class ArticlePagingSource(
private val api: ArticleApi,
private val query: String,
) : PagingSource<Int, Article>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
val page = params.key ?: 1
return try {
val items = api.search(query, page = page, pageSize = params.loadSize).map { it.toDomain() }
LoadResult.Page(
data = items,
prevKey = if (page == 1) null else page - 1,
nextKey = if (items.isEmpty()) null else page + 1,
)
} catch (e: IOException) {
LoadResult.Error(e)
} catch (e: HttpException) {
LoadResult.Error(e)
}
}
// Which page to load when the list is refreshed (e.g. after invalidation), based on scroll position
override fun getRefreshKey(state: PagingState<Int, Article>): Int? =
state.anchorPosition?.let { anchor ->
state.closestPageToPosition(anchor)?.let { it.prevKey?.plus(1) ?: it.nextKey?.minus(1) }
}
}If your API uses cursors instead of page numbers, use the cursor string as the key type.
2. Pager and ViewModel
class SearchViewModel(private val api: ArticleApi) : ViewModel() {
private val query = MutableStateFlow("android")
@OptIn(ExperimentalCoroutinesApi::class)
val results: Flow<PagingData<Article>> = query
.flatMapLatest { q ->
Pager(PagingConfig(pageSize = 20, prefetchDistance = 5, enablePlaceholders = false)) {
ArticlePagingSource(api, q)
}.flow
}
.cachedIn(viewModelScope) // survive rotation; share between collectors
fun search(q: String) { query.value = q }
}3. Compose UI with load states
@Composable
fun SearchResults(viewModel: SearchViewModel) {
val items = viewModel.results.collectAsLazyPagingItems()
when (val refresh = items.loadState.refresh) {
is LoadState.Loading -> if (items.itemCount == 0) { FullScreenLoading(); return }
is LoadState.Error -> if (items.itemCount == 0) {
FullScreenError(refresh.error.message ?: "Couldn't load", onRetry = items::retry); return
}
is LoadState.NotLoading -> if (items.itemCount == 0 && refresh.endOfPaginationReached) { EmptyState(); return }
}
LazyColumn {
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
items[index]?.let { ArticleRow(it) }
}
when (val append = items.loadState.append) {
is LoadState.Loading -> item { LoadingFooter() }
is LoadState.Error -> item { RetryFooter(onRetry = items::retry) }
else -> Unit
}
}
}4. Network + Room with RemoteMediator
For offline support, the database becomes the source of truth. Room provides the PagingSource, and a RemoteMediator fetches pages from the network into Room when the local data runs out or needs refreshing:
@OptIn(ExperimentalPagingApi::class)
class ArticleRemoteMediator(
private val api: ArticleApi,
private val db: AppDatabase,
) : RemoteMediator<Int, ArticleEntity>() {
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)
LoadType.APPEND -> db.remoteKeysDao().lastKey()?.nextPage
?: return MediatorResult.Success(endOfPaginationReached = true)
}
return try {
val response = api.list(page = page, pageSize = state.config.pageSize)
db.withTransaction {
if (loadType == LoadType.REFRESH) { db.articleDao().clearAll(); db.remoteKeysDao().clear() }
db.remoteKeysDao().insert(RemoteKey(nextPage = if (response.isEmpty()) null else page + 1))
db.articleDao().insertAll(response.map { it.toEntity() })
}
MediatorResult.Success(endOfPaginationReached = response.isEmpty())
} catch (e: IOException) {
MediatorResult.Error(e)
}
}
}
// Pager(config, remoteMediator = ArticleRemoteMediator(api, db)) { db.articleDao().pagingSource() }Testing
@Test
fun `loads first page and sets next key`() = runTest {
val source = ArticlePagingSource(FakeArticleApi(totalItems = 45), query = "kotlin")
val pager = TestPager(PagingConfig(pageSize = 20), source)
val result = pager.refresh() as PagingSource.LoadResult.Page
assertEquals(20, result.data.size)
assertEquals(2, result.nextKey)
}Where Paging fits
Search results
Network-only PagingSource, recreated per query with flatMapLatest.
Feeds with offline
RemoteMediator plus Room, so cached pages appear instantly and new pages load as needed.
Large local tables
Room's built-in PagingSource for chat history or logs with tens of thousands of rows.
Common mistakes
- Forgetting
cachedIn(viewModelScope), which reloads everything on rotation. - Transforming
PagingDataaftercachedIn. Applymapandfilterbefore caching. - Missing stable keys in the LazyColumn.
- Unstable sort order on the server, causing duplicates or gaps between pages.
Frequently asked questions
Can I modify items, such as toggling a like, in paged data?
With RemoteMediator plus Room, update the database and the paged list updates automatically. For network-only paging, invalidate the source or overlay local state.
Should I enable placeholders?
Only if you know the total count and want stable scrollbar sizing. Most apps disable them.
Is Paging overkill for 100 items?
Usually yes. Load them at once into a LazyColumn. Paging shines with large or unbounded datasets.
Conclusion
Paging 3 gives you incremental loading, deduplication, load states and retry for the price of a PagingSource. Cache in the ViewModel, render with LazyPagingItems and stable keys, and add a RemoteMediator with Room for offline-first feeds.
Next step: add user accounts with Firebase Authentication in Android.
Official references
๐ฑ Apps built by Yunok Tech
Real Android apps on Google Play, free to download.
Walzi4K wallpapers for lock and home screenGet it โธ
NotiVaultNotification history: recover dismissed notificationsGet it โธ
Ready to build real Android applications?
Networking, persistence and offline sync are where real apps get complicated. Practise building a data layer that handles slow networks, failures and caching, not just the happy path.
Move beyond theory and strengthen your Android development skills with Yunoktech's Android course. Learn core concepts, practical implementation, modern Android development techniques, and the reasoning behind building maintainable applications.
Explore the Android course and take your next step as an Android developer.