Recipe: Paginate a list (load more on scroll)¶
Problem: a long list loads a page at a time; reaching the bottom should fetch the next page, without firing duplicate requests or losing accumulated items across recomposition.
Pagination has several moving parts:
- accumulated items
- the next cursor
- an in-flight flag
- an end-of-list flag
Keep them in a small presentation state holder (the same idea as EmailFieldState in
Scaling Presenters). The presenter creates the holder with rememberRetained and drives
loading from an effect and events.
The holder¶
A plain @Stable class with private-set state and one suspend loadNext(). It’s reusable across any
cursor-paged list.
@Stable
class PagingState<T> {
private val loaded = mutableStateListOf<T>()
val items: List<T> get() = loaded
var isLoadingMore by mutableStateOf(false)
private set
var endReached by mutableStateOf(false)
private set
private var nextCursor: String? = null
private val mutex = Mutex()
// Pass the fetcher per call so the retained holder only stores paging data.
suspend fun loadNext(fetchPage: suspend (cursor: String?) -> Page<T>) {
// Return early when another load is already running.
if (endReached || isLoadingMore) return
mutex.withLock {
if (endReached) return
isLoadingMore = true
try {
val page = fetchPage(nextCursor)
loaded.addAll(page.items)
nextCursor = page.nextCursor
endReached = page.nextCursor == null
} finally {
isLoadingMore = false
}
}
}
}
The holder stores only paging data: items, cursor, and flags. Pass the fetcher to loadNext() so the
retained holder does not keep the repository on the back stack.
Mutex.withLock serializes loads so overlapping LoadMore events cannot double-fetch.
isLoadingMore is observable state for the UI’s loading spinner.
The presenter¶
Create the holder with rememberRetained so accumulated pages survive rotation and the back stack.
Load the first page from LaunchedImpressionEffect; handle LoadMore events by launching
loadNext().
@Composable
override fun present(): FeedState {
val paging = rememberRetained { PagingState<FeedItem>() }
val scope = rememberCoroutineScope()
// feedRepository comes from DI. Pass its fetcher into the retained holder.
LaunchedImpressionEffect(Unit) { paging.loadNext(feedRepository::page) } // first page on open
return FeedState(items = paging.items, isLoadingMore = paging.isLoadingMore) { event ->
when (event) {
FeedEvent.LoadMore -> scope.launch { paging.loadNext(feedRepository::page) }
}
}
}
Don’t capture the repository in the retained holder
rememberRetained { PagingState<FeedItem>() } retains only data (items, cursor, flags) — never
the repository. Constructing it as rememberRetained { PagingState(feedRepository::page) } would
capture feedRepository (and its scopes, Context, etc.) in retained state, keeping it alive
across rotation and the back stack — the same leak class as
retaining a Flow. The repository lives on the presenter via DI; the holder
receives its fetcher per loadNext() call.
The UI¶
The UI owns the LazyListState, so the “near the end” detection lives here. Use derivedStateOf so
the trigger changes only when the threshold is crossed.
@Composable
fun Feed(state: FeedState, modifier: Modifier = Modifier) {
val listState = rememberLazyListState()
val shouldLoadMore by remember {
derivedStateOf {
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
lastVisible >= state.items.lastIndex - PREFETCH_DISTANCE
}
}
LaunchedEffect(shouldLoadMore) {
if (shouldLoadMore) state.eventSink(FeedEvent.LoadMore)
}
LazyColumn(state = listState, modifier = modifier) {
items(state.items, key = { item -> item.id }) { item -> FeedRow(item) }
if (state.isLoadingMore) {
item { CircularProgressIndicator(Modifier.fillMaxWidth().wrapContentWidth()) }
}
}
}
private const val PREFETCH_DISTANCE = 5
Heavier paging? If you need placeholders, retries, and refresh out of the box,
Jetpack Paging’s
Pager exposes a Flow<PagingData<T>> you can collect with
produceRetainedState instead of hand-rolling the holder above.
See also: Observe a Flow · Scaling Presenters: state holders · Keep UI state across config change