Skip to content
advanced Phase 5 · Jetpack Libraries

Paging 3

Load and display large datasets incrementally with Paging 3 library.

50m
2 problems
Topic Progress 0%

PagingSource and Pager

Why Paging?

Loading an entire dataset into memory wastes RAM and causes slow list scrolling. Paging loads data in pages as the user scrolls, keeping memory usage constant regardless of dataset size.

PagingSource

PagingSource defines how to load pages of data. Each page load returns a LoadResult:

class ArticlePagingSource(
    private val api: ArticleApi
) : PagingSource<Int, Article>() {

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

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

Key points:

  • params.key is the page number (or cursor). Start with null for the first page.
  • params.loadSize is the number of items to request.
  • Return prevKey = null for the first page and nextKey = null when no more data exists.
  • getRefreshKey determines where to resume when the user scrolls back to the top.

Pager Configuration

val articlesPager = Pager(
    config = PagingConfig(
        pageSize = 20,
        prefetchDistance = 10,
        enablePlaceholders = false,
        initialLoadSize = 40
    ),
    pagingSourceFactory = { ArticlePagingSource(api) }
).flow
  • pageSize: items per page request
  • prefetchDistance: how many items before the end to trigger loading
  • enablePlaceholders: show empty slots for known-size lists
  • initialLoadSize: items in the first load (typically 2x pageSize)

Database-Backed Paging

For offline-first apps, use RoomPagingSource:

@Dao
interface ArticleDao {
    @Query("SELECT * FROM articles ORDER BY publishedAt DESC")
    fun getArticlesPaged(): PagingSource<Int, Article>
}
val dbArticlesPager = Pager(
    config = PagingConfig(pageSize = 20, prefetchDistance = 10),
    pagingSourceFactory = { articleDao.getArticlesPaged() }
).flow

Room returns a PagingSource directly, and the Paging library handles invalidation when data changes.

Displaying Paged Data in Compose

collectAsLazyPagingItems

In Compose, collect paged data as lazy items:

@Composable
fun ArticleListScreen(viewModel: ArticleViewModel = hiltViewModel()) {
    val articles = viewModel.articlesPager.collectAsLazyPagingItems()

    LazyColumn {
        items(
            count = articles.itemCount,
            key = articles.itemKey { it.id }
        ) { index ->
            val article = articles[index]
            article?.let { ArticleCard(article = it) }
        }

        // Loading indicator at the end
        when (articles.loadState.append) {
            is LoadState.Loading -> {
                item { LoadingIndicator() }
            }
            is LoadState.Error -> {
                item { ErrorMessage(onRetry = { articles.retry() }) }
            }
            is LoadState.NotLoading -> {}
        }
    }
}

Load States

Paging 3 exposes three load states:

  • loadState.refresh: initial load or full refresh
  • loadState.append: loading the next page
  • loadState.prepend: loading the previous page (rare)

Each state is one of: Loading, NotLoading(endOfPaginationReached), or Error(throwable).

Loading and Error UI

@Composable
fun ArticleListScreen(viewModel: ArticleViewModel = hiltViewModel()) {
    val articles = viewModel.articlesPager.collectAsLazyPagingItems()

    when (articles.loadState.refresh) {
        is LoadState.Loading -> {
            CircularProgressIndicator(modifier = Modifier.fillMaxSize())
        }
        is LoadState.Error -> {
            ErrorMessage(
                message = (articles.loadState.refresh as LoadState.Error).error.localizedMessage ?: "Error",
                onRetry = { articles.retry() }
            )
        }
        is LoadState.NotLoading -> {
            LazyColumn {
                items(count = articles.itemCount) { index ->
                    articles[index]?.let { ArticleCard(article = it) }
                }
            }
        }
    }
}

Invalidating the Pager

When the underlying data changes (e.g., new articles available), invalidate the PagingSource:

class ArticleViewModel(private val api: ArticleApi) : ViewModel() {
    val articlesPager = Pager(
        config = PagingConfig(pageSize = 20),
        pagingSourceFactory = { ArticlePagingSource(api) }
    ).flow.cachedIn(viewModelScope)

    fun refresh() {
        articlesPager.invalidate()
    }
}

cachedIn(viewModelScope) caches the Pager output in the ViewModel's scope, so multiple collectors share the same data.

Quiz

1. What does PagingSource.getRefreshKey return?

Question 1 options

2. What does the prefetchDistance parameter in PagingConfig control?

Question 2 options

3. What is the purpose of cachedIn(viewModelScope)?

Question 3 options

4. When should you invalidate a PagingSource?

Question 4 options

Flashcards

Question

What is a PagingSource and what does it return?

Answer

A PagingSource defines how to load pages of data. Its load() method returns LoadResult.Page with data, prevKey, and nextKey, or LoadResult.Error on failure.

Question

What are the three load states in Paging 3?

Answer

refresh (initial load), append (next page), and prepend (previous page). Each can be Loading, NotLoading, or Error.

Question

How do you display paged data in a LazyColumn?

Answer

Collect with collectAsLazyPagingItems(), then use items(count = pager.itemCount) with articles[index] to access each item.

Question

What does cachedIn do for a Pager?

Answer

Caches the Pager output in a CoroutineScope so multiple collectors share the same data. Prevents duplicate network requests.

Revision Notes

Key Takeaways

  • 1. PagingSource defines how to load pages; return LoadResult.Page with prevKey/nextKey
  • 2. PagingConfig controls page size, prefetch distance, and initial load size
  • 3. collectAsLazyPagingItems integrates paging data into LazyColumn composables
  • 4. LoadState tells you whether refresh, append, or prepend is loading, done, or errored
  • 5. cachedIn(viewModelScope) caches and shares paged data across multiple collectors

Interview Tips

  • Explain the PagingSource load cycle: load -> Page/Error -> next load
  • Discuss how getRefreshKey determines where to resume after refresh
  • Know how to handle loading and error states in the UI with LoadState
  • Be ready to design an infinite scroll list with Paging 3 and LazyColumn

Cheat Sheet

Paging 3 Cheat Sheet

PagingSource:

  • Extend PagingSource<Key, Value>
  • load(params) returns LoadResult.Page(data, prevKey, nextKey) or LoadResult.Error
  • getRefreshKey(state) returns resume position
  • Return nextKey = null when no more data

Pager:

  • Pager(config, pagingSourceFactory) creates a Flow of paging data
  • pageSize: items per page
  • prefetchDistance: trigger distance from end
  • initialLoadSize: first page size (default 2x pageSize)

Compose Integration:

  • collectAsLazyPagingItems() — collects as LazyPagingItems
  • items(count = pager.itemCount) — renders items
  • articles[index] — null if not loaded, item if loaded
  • articles.loadState.refresh/append — loading states

Load States:

  • LoadState.Loading — fetching data
  • LoadState.NotLoading(endOfPaginationReached) — done
  • LoadState.Error(exception) — failure, use retry()

Caching:

  • .cachedIn(viewModelScope) — shares data across collectors
  • Prevents duplicate requests on recomposition

Invalidation:

  • pagingSource.invalidate() — force reload from scratch
  • Use when underlying data changes