Paging 3 with Compose

The previous lesson's api.getCommunityRecipes() quietly assumed the whole feed fits in one response. That's fine for a few dozen recipes, but RecipeBox's real community feed has grown to tens of thousands of entries — fetching all of them in a single request would be slow, waste bandwidth, and use far more memory than the screen could ever show at once. Paging 3 is Jetpack's library for loading a large, scrollable dataset a page at a time, and it plugs directly into a Compose LazyColumn.

Why Loading Everything at Once Breaks Down

A single List<Recipe> holding 20,000 community recipes means one enormous network response, one enormous JSON parse, and 20,000 Recipe objects sitting in memory — for a screen that shows perhaps 8 of them on screen at a time. Paging 3 fixes this at the source: it requests only a page's worth of data (say, 20 items) at a time, requests the next page automatically as the user scrolls near the end of what's loaded, and only keeps a bounded window of pages in memory at once.

PagingSource: Describing How to Load One Page

A PagingSource is the piece that knows how to fetch a single page given a page key (here, a page number the backend understands) and how to figure out the keys for the previous and next pages:

CommunityRecipePagingSource.kt

class CommunityRecipePagingSource(
    private val api: RecipeApiService
) : PagingSource<Int, Recipe>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Recipe> {
        val page = params.key ?: 1
        return try {
            val response = api.getCommunityRecipes(page = page, pageSize = params.loadSize)
            LoadResult.Page(
                data = response,
                prevKey = if (page == 1) null else page - 1,
                nextKey = if (response.isEmpty()) null else page + 1
            )
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }

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

nextKey = null once the backend returns an empty page tells Paging 3 it has reached the end of the feed and to stop requesting further pages. getRefreshKey tells Paging 3 which page to reload around if the whole list needs to refresh — for example after a pull-to-refresh — so the user's scroll position is preserved instead of snapping back to page one.

Pager: Wiring the PagingSource into a Flow of PagingData

The ViewModel builds a Pager around the PagingSource, configures page size, and exposes the result as a Flow<PagingData<Recipe>> — cached across configuration changes with cachedIn(viewModelScope) so rotating the screen doesn't re-fetch every page from scratch:

CommunityFeedViewModel.kt

class CommunityFeedViewModel(
    private val api: RecipeApiService
) : ViewModel() {

    val recipePagingData: Flow<PagingData<Recipe>> = Pager(
        config = PagingConfig(pageSize = 20, prefetchDistance = 5),
        pagingSourceFactory = { CommunityRecipePagingSource(api) }
    ).flow.cachedIn(viewModelScope)
}

prefetchDistance = 5 tells Paging 3 to start loading the next page once the user has scrolled to within 5 items of the end of what's currently loaded — the next page is usually ready before the user even reaches the bottom.

collectAsLazyPagingItems() Inside a LazyColumn

The Compose integration artifact (androidx.paging:paging-compose) provides collectAsLazyPagingItems(), which turns that Flow<PagingData<Recipe>> into a LazyPagingItems<Recipe> you can hand straight to a LazyColumn's items():

CommunityFeedScreen.kt

@Composable
fun CommunityFeedScreen(
    viewModel: CommunityFeedViewModel = viewModel()
) {
    val recipes = viewModel.recipePagingData.collectAsLazyPagingItems()

    LazyColumn(contentPadding = PaddingValues(16.dp)) {
        items(count = recipes.itemCount) { index ->
            val recipe = recipes[index]
            if (recipe != null) {
                RecipeRow(recipe = recipe)
            }
        }

        when (val appendState = recipes.loadState.append) {
            is LoadState.Loading -> {
                item {
                    Box(modifier = Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) {
                        CircularProgressIndicator()
                    }
                }
            }
            is LoadState.Error -> {
                item {
                    Column(
                        modifier = Modifier.fillMaxWidth().padding(16.dp),
                        horizontalAlignment = Alignment.CenterHorizontally
                    ) {
                        Text(text = "Couldn't load more recipes")
                        Button(onClick = { recipes.retry() }) {
                            Text("Retry")
                        }
                    }
                }
            }
            else -> Unit
        }
    }
}

recipes.itemCount and recipes[index] are how LazyPagingItems plugs into items() — indexing past what's currently loaded returns null and quietly triggers Paging 3 to load the page that index belongs to, which is why the composable handles a nullable recipe. recipes.loadState.append reflects the state of loading more pages onto the end of the list specifically — separate from loadState.refresh, which reflects the very first page load — so a failure appending page 6 shows an inline retry row at the bottom without disturbing the 5 pages already displayed above it.

Note: This is correct, real Paging 3/Compose code — but this website's own project doesn't run a live Android Studio build, so no request actually fires from this page.
Next up: Every screen built across this track has been described in code, but never actually verified automatically. Compose UI Testing covers writing real tests against these composables.