Pagination is one of the most common use cases in mobile app development. Whether you’re building a news feed, an e-commerce app, or a movie browser, you don’t want to load thousands of items at once — that would slow down your app and drain resources. Instead, we load data in chunks, also known as pages, as the user scrolls.
In this article, we’ll break down how to implement infinite pagination using Compose multiplatform. We’ll focus on two core components:
- ✅ Paginator — handles the logic of fetching new data when needed.
- 📡 Scroll Detection — detects when the user scrolls near the end of the list.

1. ✅ Paginator — Managing the Fetch Logic
The Paginator is a reusable class responsible for:
- Managing the current page index
- Preventing duplicate or parallel requests
- Handling loading, success, and error states
- Updating your ViewModel state when new data is fetched
Let’s look at a simplified Paginator class:
class Paginator<T>(
private val scope: CoroutineScope,
private val initialKey: Int = 1,
private val incrementBy: Int = 1,
private val onLoadUpdated: (Boolean) -> Unit,
private val onRequest: suspend (nextKey: Int) -> Flow<UiState<List<T>>>,
private val onError: suspend (Throwable) -> Unit,
private val onSuccess: suspend (items: List<T>, newKey: Int) -> Unit
) {
private var isMakingRequest = false
private var currentKey = initialKey
private var hasError = false // NEW FLAG
fun reset() {
currentKey = initialKey
hasError = false
}
fun loadNextItems() {
if (isMakingRequest || hasError) return // Prevent further loading if error occurred
isMakingRequest = true
scope.launch {
onLoadUpdated(true)
val resultFlow = onRequest(currentKey)
resultFlow.collect { result ->
when (result) {
is UiState.Loading -> Unit
is UiState.Success -> {
val items = result.data.orEmpty()
val nextKey = currentKey + incrementBy
onSuccess(items, nextKey)
currentKey = nextKey
}
is UiState.Error -> {
hasError = true // STOP FURTHER PAGINATION
onError(result.exception)
}
}
}
onLoadUpdated(false)
isMakingRequest = false
}
}
}
2. 📡 Scroll Detection — When to Fetch the Next Page
Once we have our Paginator ready, we need to know when to trigger it. That’s where scroll detection comes in.
In Jetpack Compose, we can use the scroll state of a list (like LazyVerticalGrid or LazyColumn) to determine when the user has scrolled near the bottom and trigger the next page fetch.
Here’s a handy composable for that:
@Composable
fun OnGridPagination(
gridState: LazyGridState,
buffer: Int = 5,
onLoadMore: () -> Unit
) {
val shouldLoadMore = remember {
derivedStateOf {
val layoutInfo = gridState.layoutInfo
val totalItems = layoutInfo.totalItemsCount
val lastVisibleItem = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
lastVisibleItem >= totalItems - buffer
}
}
LaunchedEffect(shouldLoadMore.value) {
if (shouldLoadMore.value) {
onLoadMore()
}
}
}
🎥 Movie Grid UI
We show each movie in a grid with image thumbnails.
@Composable
internal fun Movies(
listItems: List<MovieItem>,
gridState: LazyGridState,
onclick: (id: Int) -> Unit
) {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 180.dp),
state = gridState,
modifier = Modifier.padding(start = 5.dp, end = 5.dp, top = 10.dp),
) {
items(listItems) {
Column(
modifier = Modifier.padding(
start = 5.dp, end = 5.dp, top = 0.dp, bottom = 10.dp
)
) {
CoilImage(
imageModel = { AppConstant.IMAGE_URL.plus(it.posterPath) },
imageOptions = ImageOptions(
contentScale = ContentScale.Crop,
alignment = Alignment.Center,
contentDescription = "Movie item",
),
component = rememberImageComponent {
+CircularRevealPlugin(duration = 800)
},
modifier = Modifier
.height(250.dp)
.fillMaxWidth()
.cornerRadius(10)
.shimmerBackground(RoundedCornerShape(5.dp))
.clickable { onclick(it.id) },
)
}
}
}
}
🧠ViewModel with paginator
@Composable
fun PopularMovie(
navigator: Navigator,
viewModel: PopularMovieViewModel = viewModel { PopularMovieViewModel() }
) {
val uiState by viewModel.uiState.collectAsState()
val gridState = rememberLazyGridState()
LaunchedEffect(Unit){
viewModel.loadPopularMovies()
}
BaseColumn(
loading = uiState.isLoading,
errorMessage = uiState.errorMessage
) {
uiState.movieList?.let {
Movies(it, gridState) { movieId ->
navigator.navigate(NavigationScreen.MovieDetail.route + "/$movieId")
}
OnGridPagination(gridState = gridState) {
viewModel.loadPopularMovies()
}
}
}
}
🖼️ PopularMovie UI
@Composable
fun PopularMovie(
navigator: Navigator,
viewModel: PopularMovieViewModel = viewModel { PopularMovieViewModel() }
) {
val uiState by viewModel.uiState.collectAsState()
val gridState = rememberLazyGridState()
LaunchedEffect(Unit){
viewModel.loadPopularMovies()
}
BaseColumn(
loading = uiState.isLoading,
errorMessage = uiState.errorMessage
) {
uiState.movieList?.let {
Movies(it, gridState) { movieId ->
navigator.navigate(NavigationScreen.MovieDetail.route + "/$movieId")
}
OnGridPagination(gridState = gridState) {
viewModel.loadPopularMovies()
}
}
}
}
Github: https://github.com/piashcse/kmp-movie
Medium: https://piashcse.medium.com/pagination-in-compose-multiplatform-without-any-library-77281b18a4cd