Paging 3 Explained

ARCHITECTURE › Components

Paging 3 solves one problem: show the user a list that might have thousands of rows without loading them all into memory or firing one giant network or database call up front. Three pieces work together. A PagingSource knows how to fetch one page of data given a key (page number, cursor, timestamp). A Pager wraps a PagingSource factory plus a PagingConfig and exposes a Flow<PagingData<T>>. And PagingData is a stream of incremental updates, pages appended or refreshed, that a PagingDataAdapter (Views) or collectAsLazyPagingItems() (Compose) consumes and renders, requesting more automatically as the user scrolls near the loaded edge.

val pager = Pager(PagingConfig(pageSize = 20)) { ItemPagingSource(api) }
val items: Flow<PagingData<Item>> = pager.flow

Everything else in this lesson is detail on how each piece behaves.

PagingSource.load() is the core method you implement. It receives LoadParams<Key> (a key and a requested load size) and must return one of three LoadResult types:

- **LoadResult.Page**: success, carries the data plus prevKey or nextKey. A null key on either side tells Paging there's nothing more to load in that direction. - **LoadResult.Error**: the load failed, surfaced to the UI as LoadState.Error. - **LoadResult.Invalid**: the source is stale (say, the underlying data changed), so Paging discards it and asks the Pager factory for a brand new PagingSource.

override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Item> {
    val page = params.key ?: 1
    val response = api.getItems(page)
    return LoadResult.Page(
        data = response.items,
        prevKey = if (page == 1) null else page - 1,
        nextKey = if (response.items.isEmpty()) null else page + 1
    )
}

getRefreshKey() answers a specific question: after a PagingSource is invalidated and a fresh one is created, which key should the *new* load() call start from, so the user doesn't get yanked back to the top of the list? Paging gives you a PagingState with anchorPosition, the index of the item closest to the user's current scroll position (not a usable key by itself).

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

closestPageToPosition finds the nearest already-loaded page, and you derive a key from its prevKey or nextKey. Returning null tells Paging to just restart from the initial key.

PagingConfig tunes how aggressively Paging loads. pageSize is items per page. prefetchDistance is how close to the loaded edge the user has to scroll before the next page is requested, it defaults to pageSize. initialLoadSize is how much the very first load fetches, so the screen starts reasonably full, it defaults to pageSize * 3. enablePlaceholders shows null rows for not-yet-loaded items, but only works if the source can report a total count (Room does this automatically via COUNT(*)).

PagingConfig(pageSize = 20)
// initialLoadSize defaults to 60, prefetchDistance defaults to 20

A Pager.flow is cold by default, each new collector would trigger its own fresh loading from scratch. In a ViewModel, you almost always chain .cachedIn(viewModelScope) onto it:

val items: Flow<PagingData<Item>> = Pager(
    config = PagingConfig(pageSize = 20)
) { ItemPagingSource(api) }
    .flow
    .cachedIn(viewModelScope)

cachedIn() caches the already-loaded PagingData inside the given CoroutineScope and multicasts it to collectors, so a rotation, which recreates the UI and starts a new collection, reuses the pages already loaded instead of refetching from page one.

When you're paging from network into a local database, add a RemoteMediator. The key idea: the **database becomes the single source of truth**, the PagingSource the UI actually collects from reads out of Room, not the network, while the mediator's load() fetches network pages and writes them into that database.

override suspend fun load(loadType: LoadType, state: PagingState<Int, Item>): MediatorResult {
    val response = api.getItems(page)
    db.withTransaction {
        if (loadType == LoadType.REFRESH) db.itemDao().clearAll()
        db.itemDao().insertAll(response.items)
    }
    return MediatorResult.Success(endOfPaginationReached = response.items.isEmpty())
}

loadType is REFRESH, PREPEND, or APPEND. MediatorResult.Success(endOfPaginationReached) tells Paging whether there's more to fetch in that direction; you can also override initialize() to skip a redundant network refresh when cached data is still fresh.

The UI drives loading and error indicators off CombinedLoadStates, which groups a LoadState (Loading, NotLoading, Error) per direction. refresh covers the initial load or an explicit refresh, so it's what should drive a full-screen spinner or an initial error screen. append and prepend cover loads at the list's bottom and top, so they drive inline footer or header indicators while scrolling.

when {
    items.loadState.refresh is LoadState.Loading -> FullScreenSpinner()
    items.loadState.refresh is LoadState.Error -> FullScreenError { items.retry() }
    else -> ItemList(items)
}

For a retry button, call retry(), it re-attempts only the failed load(s) without resetting the list. refresh() is different, it reloads everything from scratch, so it's the wrong call for a retry action.

In Compose, pager.flow.collectAsLazyPagingItems() gives you a LazyPagingItems<T> to render:

val items = viewModel.items.collectAsLazyPagingItems()
LazyColumn {
    items(
        count = items.itemCount,
        key = items.itemKey { it.id }
    ) { index ->
        val item = items[index]
        if (item != null) ItemRow(item) else ItemPlaceholder()
    }
}

itemKey { it.id } gives stable identity across shifting indices and placeholders, using the raw index as a key breaks down as pages load in. One more ordering rule worth remembering: any .map { } or .insertSeparators { } transform on the PagingData should sit **before** .cachedIn() in the chain, so the cached result is already transformed rather than being redone for every new collector.

Back to Paging 3