Jetpack Compose Pagination: Paging3 vs Alternative Approach

Jetpack Compose Pagination: Paging3 vs Alternative Approach

Learn to implement pagination efficiently in Jetpack Compose with the Paging3 library, and explore an alternative lightweight approach for smooth app performance.
Hoang Nguyen
Hoang Nguyen
April 01, 2025
Android Mobile

Table of Contents

Introduction

Pagination is a widely used UX approach in modern app development that helps users navigate through large amounts of data intuitively. From news feeds to photo galleries or music libraries, pagination creates a seamless scrolling experience through vast content collections, significantly enhancing user experience.

Jetpack Compose makes building dynamic interfaces with efficient pagination more straightforward than ever. In this article, we’ll explore pagination with Jetpack Compose, comparing different implementation approaches and analyzing the pros and cons of each solution. Whether you’re an experienced developer or just starting with Jetpack Compose, this guide provides valuable insights on implementing pagination to elevate your application.

Let’s dive into pagination with Jetpack Compose! 📖

Building Crypto Price Tracker Application With Jetpack Compose

In this article, we will build an application to show a list of trending cryptocurrencies. The data for this long list will be fetched from the Coingecko API.

The following demonstration shows how the application looks when it is complete:

The Crypto Price Tracker application built with Jetpack Compose
The Crypto Price Tracker application built with Jetpack Compose

Our project follows modern development practices, utilizing MVVM and Clean Architecture. Data flows from the Repository layer through the ViewModel and finally present to the UI. As users scroll down through the Trending 🔥 section, the app automatically sends API requests to fetch the next page of data, seamlessly adding new items to the end of the current list. Now, Let’s move to the next section to learn about implementing pagination with two approaches.

📌 Checkout the full source code on Github 🚀

Without Paging3

Implementing pagination without Paging3 is more straightforward with just a few steps. The main idea of this approach is simple: observe the last visible item’s index and trigger loading of the next page when needed. 💡

The first step is to create a new Composable for the Home screen, which contains the coins list:

@Composable
private fun HomeScreenContent(
    ...
    showTrendingCoinsLoading: LoadingState, // Observe the loading state to show the indicator
    trendingCoins: List<CoinItemUiModel>, // Data models to display on UI
    onTrendingCoinsLoadMore: () -> Unit = {} // Trigger loading more callback whenever reach end of list
    ...
) {

...

}

To display the trending coin items, create a new composable called TrendingItem. This composable will bind the data from CoinItemUiModel to the UI. Here is the composable in preview mode:

TrendingItem in Preview
TrendingItem in Preview

Add the LazyColumn inside the HomeScreenContent to display coin information. With itemsIndexed, there is a callback to trigger the current item’s index shown on the UI. The threshold constant targets the remaining items to preload when users nearly reach the end of the list.

    private const val LIST_ITEM_LOAD_MORE_THRESHOLD = 0
    ...
    val trendingCoinsLastIndex = trendingCoins.lastIndex
    val trendingCoinsState = rememberLazyListState()
    ...
    LazyColumn(state = trendingCoinsState) {
        itemsIndexed(trendingCoins) { index, coin ->
            if (index + LIST_ITEM_LOAD_MORE_THRESHOLD >= trendingCoinsLastIndex) {
                SideEffect {
                    Timber.d("onTrendingCoinsLoadMore at index: $index, lastIndex: $trendingCoinsLastIndex")
                    onTrendingCoinsLoadMore.invoke()
                }
            }

            Box(
                modifier = Modifier.padding(
                start = Dp16, end = Dp16, bottom = Dp16
                )
            ) {
                TrendingItem(
                    modifier = ...
                    coinItem = coin,
                    onItemClick = { onTrendingItemClick.invoke(coin) }
                )
            }
        }
    }

Add the if condition to validate the loading state and show loading indicator at the bottom of list whenever the next page of data is being fetched.

if (showTrendingCoinsLoading == LoadingState.LoadingMore) {
    item {
        CircularProgressIndicator(
            modifier = Modifier
                .fillMaxWidth()
                .wrapContentWidth(align = Alignment.CenterHorizontally)
                .padding(bottom = Dp16),
        )
    }
}

That’s it! 💪 Build the project, then see the result 🪄

With Paging3

In this section, we are going to implement pagination with Paging3 which provides robust support for handling and displaying paginated data. There are some essential parts before starting implement Paging3:

  • PagingSource: Defines a source of data and how to retrieve it from a data source such as remote APIs or databases.
  • RemoteMediator: Handles paging from layered data sources, like network data with local database caching.
  • PagingConfig: Configures how pagination behaves, including page sizes and prefetching distances.
  • Pager: Creates flows of PagingData based on your configuration.
  • PagingData: A container for paginated data that includes loading state information.
  • LazyPagingItems: Connects with Lazy composable components to quickly present data using the items{} built for LazyListScope.

Here we go to integrate Paging3 to the application:

  • Firstly, add the dependency to build.gradle (:app)
implementation "androidx.paging:paging-compose:3.3.6"

Feel free to replace with a newer version of this dependency 🛠️

  • Define the PagingSource as a source of data and retrieve data from PagingSource to show in UI.
class CoinPagingSource() : PagingSource<Int, CoinItem>()

In the demonstration project, the CoinPagingSource in the :domain module needs to inherit the PagingSource from the androidx.paging library; it requires adding the dependency to build.gradle (:domain) .

implementation "androidx.paging:paging-common:3.3.6"

The PagingSource class requires 2 dependencies:

  • CoinRepository: which will load data from API
  • Query: which is provide fields for API request
class CoinPagingSource(
    private val coinRepository: CoinRepository,
    private val query: Query
) : PagingSource<Int, CoinItem>() {

    data class Query(
        val currency: String,
        val order: String,
        val priceChangeInHour: String
    )
}

There are 2 override functions getRefreshKey(...) & load(...) to return paging data to display on the UI:

  • getRefreshKey provides a Key used for the initial load for the next PagingSource due to invalidation of this PagingSource. The Key is provided to load(...) via LoadParams.Key.
override fun getRefreshKey(state: PagingState<Int, CoinItem>): Int? {
    // Try to find the page key of the closest page to anchorPosition, from
    // either the prevKey or the nextKey, but you need to handle nullability
    // here:
    //  * prevKey == null -> anchorPage is the first page.
    //  * nextKey == null -> anchorPage is the last page.
    //  * both prevKey and nextKey null -> anchorPage is the initial page, so
    //    just return null.
    return state.anchorPosition?.let { anchorPosition ->
        state.closestPageToPosition(anchorPosition)?.prevKey?.plus(
            1
        ) ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(
            1
        )
    }
}
  • load(params: LoadParams<Int>) is the callback function to load more data from API
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, CoinItem> = withContext(Dispatchers.IO) {
    suspendCoroutine { continuation ->
        runBlocking {
            val page = params.key ?: STARTING_PAGE_INDEX
            val size = params.loadSize
            coinRepository.getCoins(
                currency = query.currency,
                priceChangePercentage = query.priceChangeInHour,
                itemOrder = query.order,
                page = page,
                itemPerPage = size
            ).catch {
                continuation.resume(LoadResult.Error(it))
            }.collectLatest { items ->
                val prevKey = if (page == STARTING_PAGE_INDEX) null else page - 1
                val nextKey = if (items.isEmpty()) null else page + 1

                val result = if (params.placeholdersEnabled) {
                    val itemsBefore = page * size
                    val itemsAfter = itemsBefore + items.size
                    LoadResult.Page(
                        data = items,
                        prevKey = prevKey,
                        nextKey = nextKey,
                        itemsAfter = if (itemsAfter > size) size else itemsAfter,
                        itemsBefore = if (page == STARTING_PAGE_INDEX) 0 else itemsBefore,
                    )
                } else {
                    LoadResult.Page(
                        data = items,
                        prevKey = prevKey,
                        nextKey = nextKey
                    )
                }
                continuation.resume(result)
            }
        }
    }
}
  • The key is used to determine the latest page index that was fetched. On initial load, there is no key added, so the value can be null, which means that the first page should be 1.
val page = params.key ?: STARTING_PAGE_INDEX
  • Paging3 provides PagingConfig to predefine various properties on paging data.
PagingConfig(
    pageSize = itemPerPage, // Number of items per page
    enablePlaceholders = true, // Show the loading placeholder on requesting API
    prefetchDistance = LIST_ITEM_LOAD_MORE_THRESHOLD, // Remaining items to trigger preload
    initialLoadSize = itemPerPage // Number of items on initial load
)
  • The Paging library supports using several stream types, including Flow, LiveData, and the Flowable and Observable types from RxJava. On this blog post, we are going to use Flow for the data stream:
Pager(
    config = PagingConfig(
        ...
    ),
    pagingSourceFactory = {
        ...
    }
).flow
  • Let’s combine the piece of code above and add into the UseCase:
private const val LIST_ITEM_LOAD_MORE_THRESHOLD = 2

class GetTrendingCoinsPaginationUseCase @Inject constructor(private val repository: CoinRepository) {

    data class Input(
        val currency: String,
        val order: String,
        val priceChangeInHour: String,
        val itemPerPage: Int
    )

    fun execute(input: Input): Flow<PagingData<CoinItem>> {
        return with(input) {
            Pager(
                config = PagingConfig(
                    pageSize = itemPerPage,
                    enablePlaceholders = true,
                    prefetchDistance = LIST_ITEM_LOAD_MORE_THRESHOLD,
                    initialLoadSize = itemPerPage
                ),
                pagingSourceFactory = {
                    CoinPagingSource(
                        repository,
                        CoinPagingSource.Query(
                            currency = currency,
                            order = order,
                            priceChangeInHour = priceChangeInHour
                        )
                    )
                }
            ).flow
        }
    }
}
  • On the HomeViewModel, let’s provide a Flow to provide the stream of PagingData to the UI.
private val _trendingCoins = MutableStateFlow<PagingData<CoinItemUiModel>>(PagingData.empty())
override val trendingCoins: StateFlow<PagingData<CoinItemUiModel>>
    get() = _trendingCoins
  • Then, define the new method to invoke the created UseCase to fetch the data.
override fun getTrendingCoins() {
    execute {
        getTrendingCoinsPaginationUseCase.execute(
            GetTrendingCoinsPaginationUseCase.Input(
                currency = FIAT_CURRENCY,
                order = MY_COINS_ORDER,
                priceChangeInHour = MY_COINS_PRICE_CHANGE_IN_HOUR,
                itemPerPage = MY_COINS_ITEM_PER_PAGE
            )
        ).catch { e ->
            _trendingCoinsError.emit(e)
        }.cachedIn(
            viewModelScope
        ).collect { coins ->
            val newCoinList = coins.map { it.toUiModel() }
            _trendingCoinsPagination.emit(newCoinList)
        }
    }
}
  • On the HomeScreen, observe the trendingCoinsPagination flow to bind the coin items whenever the new page of data is added.
@Composable
fun HomeScreen(
    viewModel: HomeViewModel = hiltViewModel(),
    ....
) {
    ...
    val trendingCoinsPagination = viewModel.trendingCoinsPagination.collectAsLazyPagingItems()
    ...
}
  • Similar to the first approach without Paging3, the HomeScreenContent will receive the items to bind the coin data on the UI.
@Composable
private fun HomeScreenContent(
    ...
    trendingCoinsPagination: LazyPagingItems<CoinItemUiModel>,
    ...
) {

...

}
  • If you’re curious why there is no loading states with this approach 🤔 Then how would we handle the different state on loading 🤷 The answer is loadState provided through the LazyPagingItems which combined loading states:
LazyPagingItems provides loading states
LazyPagingItems provides loading states
  • Now, we can use the paging data to bind the data to the UI:
items(
    count = trendingCoinsPagination.itemCount,
    contentType = trendingCoinsPagination.itemContentType(),
    key = trendingCoinsPagination.itemKey { it.id },
) { index ->
    Box(
        modifier = Modifier.padding(
            start = Dp16, end = Dp16, bottom = Dp16
        )
    ) {
        TrendingItem(
            modifier = ...,
            coinItem = trendingCoinsPagination[index],
            ...
        )
    }
}
  • Let’s run the project and take a look at the outcome 🎉

Comparison

This comparison summarizes the pros and cons of using Paging3 compared to the approach without it:

  Without Paging3 With Paging3
Implementation simplicity  
Enhanced integration & maintenance  
Supports different data sources (remote and local)  
Loading placeholder  
Preview

Without Paging3:

  • Pros:
    • Simple and quick implementation to handle fetching more items
    • Provides paging data without wrapping class
    • Simple handling of UI Preview
  • Cons:
    • Complexity in supporting different data sources
    • No placeholder when loading items

With Paging3:

  • Pros:
    • Suitable for handling more complex cases like fetching data from a single data source or combining remote and local databases
    • Supports placeholder when loading items, which is a popular UI/UX trend in modern applications
  • Cons:
    • Requires more boilerplate code, including handling the PagingSource class, wrapping data with PagingData, and providing config to the Pager class

Conclusion

While Paging3 provides many benefits, it does require a significant amount of boilerplate code to handle communication between the Data layer and UI layer. This added complexity can make implementation more challenging and time-consuming. Additionally, testing with Paging3 can be cumbersome and may require significant effort to mock data source functionality. These challenges can be especially difficult for developers who are new to Paging3 or have limited experience with pagination. 💪

Despite these challenges, many developers prefer to use Paging3 due to its support for different data sources (such as remote and local) and loading placeholders. Ultimately, the decision to use Paging3 will depend on the specific needs and resources of your project. ✌️

References

If this is the kind of challenges you wanna tackle, Nimble is hiring awesome web and mobile developers to join our team in Bangkok, Thailand, Ho Chi Minh City, Vietnam, and Da Nang, Vietnam✌️

Join Us

Recommended Stories:

Accelerate your digital transformation.

Subscribe to our newsletter and get latest news and trends from Nimble