How to Use the Android Paging3 Library With Jetpack Compose
Turn the Google Books pages in your app

This article will focus on explaining how to implement pagination in Android, following the latest trends in Android development. We will utilise the latest version of paging in Android — Paging3 and will explore how to integrate this with Jetpack Compose.
Spoiler alert: it’s simpler than you think!
To page or not to page — that is the question
When should you implement pagination? If it is a relatively small set of data that needs to be fetched and you know you need to present all of this data on the UI, there is no need to page the response.
Paging makes sense when you are loading large amounts of data, that you might, or might not need, depending on user interaction. Or, when the amount of data is not predictable — you don’t know how big the response will be before you actually make the call. In cases like that it’s smart to use pagination, to gradually fetch the data and present it to the user as they scroll through the page. This is known as infinite scrolling, and in this article, we’ll take a look at how to implement it using Google’s latest paging library and Jetpack Compose.
1. The Basics
First of all, we will need an API that supports paging. And for that purpose, we’ll use Google’s Book API. Pretty convenient, I know.
We will be making a one-screen application that consumes this API and allows the user to search for a book. And we’ll utilise paging for compose, to make the loading of the books faster and more efficient. We’ll only load 10 books per request, and as the user scrolls, we’ll load more. That way we only fetch as many books as we need, making the network requests faster and the user experience better.
We will use the latest versions of the dependencies to this date:
implementation "androidx.paging:paging-runtime:3.1.1"
implementation "androidx.paging:paging-compose:1.0.0-alpha16"
Please make sure you always use compatible versions of the standard paging library and paging for compose. Incompatible versions may lead to crashes.
Note: For the sake of simplicity, I will omit using Koin or other dependency injection frameworks in this example.
2. The Data Layer
The data layer is the same as any other — consisting of a repository and an API-consuming service, implemented with Retrofit in this case:
The only key
difference is the Paging Source, which is a specific part of the Paging library. This is where the magic happens:
The query
parameter is there only for the search functionality and is not directly related to the paging.
As you can see, this class needs to inherit from the PagingSource abstract class and implement two methods. Let’s implement them:
In the load()
method, we make the call to the API. Of course, we need the limit
and startIndex
parameters here. These are provided for us by the Paging library. The limit parameter indicates how many items should be fetched in one call. It is defined by the loadSize
property of the LoadParams
.
On the other hand, the startIndex
parameter represents the index of the first element from where we want to start the fetch (this might vary between APIs, some use page numbers instead). It is provided by the key property of the LoadParams
.
We make the call to the endpoint with these two properties and wrap the result in a Page
instance. Here we also need to provide the previous and the next key. This is essential for fetching the data properly. The logic here is that the prevKey
should represent the previous page and the nextKey
should indicate which page should be loaded next (it will be passed to this method the next time it is called as key
).
In case we reach the final page, we pass null
, to indicate that the whole data has been loaded.
override fun getRefreshKey(state: PagingState<Int, Book>): Int =
((state.anchorPosition ?: 0) - state.config.initialLoadSize / 2)
.coerceAtLeast(0)
The getRefreshKey()
method provides information to the library on which page to load in case the data is invalidated.
Implementing this method improperly can lead to reloading the data whenever a refresh is performed, rather than updating the current list and keeping the position in the list.
3. The ViewModel
The ViewModel represents a link between the View and the Data layer. It’s pretty simple when it comes to paging through. You only need a few lines to connect everything together.
Simple as that. We create a Pager
with a PagingConfig
and provide the pager with a PagingSource
. Then we wrap all this into a flow to make it easy to consume by the UI. That’s it!
4. The View
I will assume that you have a basic knowledge of Compose and that you know how to create a composable screen and render it in an activity. If you don’t, you can find the full code at the end of this article and see how exactly everything is wired together.
Remember I said that wrapping the Pager in a Flow will be useful for later? Here’s how convenient it’s to consume the flow in the UI — one line:
mainViewModel.bookPager.collectAsLazyPagingItems()
What does this line do? Basically, exactly what its name says — it collects the flow’s data as lazy items. And that’s exactly what we need. Compose knows how to handle this object and to request more items when the user reaches the end of the list. So, we pass this object to the composable screen.
The LazyPagingItems
can be passed to one of the lazy layouts provided by Compose: LazyColumn
, LazyRow
, LazyHorizontalGrid
or LazyVerticalGrid
. More on that can be found here.
One more important thing to note here is handling the loading states. The LazyPagingItems
contains information the LoadState
. In this example, we are only reacting to the refresh
event (initial load or invalidating the data source), but you can also handle the append
, prepend
and a couple more, and show the appropriate UI to the user.
5. Analysis
Please note that the excerpts in this article are exactly that — excerpts and are (probably) not compilable because I intentionally left out parts of the code to make it legible. You can find a link to the full code near the end of the article.
Now, let’s see how everything works together.
I’ve added numbers to the items & attached the Network Inspector to the application to show you how paging works in the background.

As you can see in the code, we’ve set the load size to be 10. But in the initial load, the maxResults
parameter is 30. Why?
The paging library uses preloading — it loads the requested load size * 3
, by default. This is so that the user has enough content to look at when the data is initially loaded. All subsequent loads are indeed fetching 10 items per request. You can alter this behaviour by defining the initialLoadSize
property of the PagingConfig
.
So, initially, we’ve loaded 30 items. But when we get to the 20th item, a new request is made. This is another feature of the Paging library known as prefetchDistance
. It defaults to the pageSize
you’ve defined in the PagingConfig
, but if you want, you can override it when defining the PagingConfig
.
6. Epilogue
Paging3 offers convenient methods to load items lazily and present it to the user without noticeable delays in the UI. And while you can use it with the old XML-defined views, the integration with Compose is really seamless. Use paging whenever you want to load big sets of data instead of fetching it all at once.
The full code can be found on GitHub:
I’ve published a follow-up article that extends on this codebase. If you care curious about side-effects in Jetpack Compose, take a look at Splash Screen with Jetpack Compose: Side-Effects in Compose & How to Use Them