Pagination is one of those problems that looks solved until you try to extend it. Offset-based pagination breaks under concurrent inserts. Paging 3 is powerful but has significant API surface area. Most hand-rolled solutions leak infrastructure details into the ViewModel.
Here's the pattern I keep returning to.
The Interface
data class Page<T>(
val items: List<T>,
val nextCursor: String?,
val hasMore: Boolean
)
interface PaginatedSource<T> {
suspend fun load(cursor: String?, pageSize: Int): Result<Page<T>>
}
The ViewModel knows about Page<T> and PaginatedSource<T>. It knows nothing about HTTP, SQL, or offsets. Those are implementation details of whatever class implements the interface.
Cursor-based pagination survives concurrent writes. If new items are inserted between pages, the cursor still points to the correct position. Offsets silently skip or duplicate items.
The Coordinator
class PaginationCoordinator<T>(
private val source: PaginatedSource<T>,
private val pageSize: Int = 20,
private val scope: CoroutineScope
) {
private val _state = MutableStateFlow(PaginationState<T>())
val state = _state.asStateFlow()
fun loadNext() {
val current = _state.value
if (current.isLoading || !current.hasMore) return
scope.launch {
_state.update { it.copy(isLoading = true) }
source.load(current.nextCursor, pageSize)
.onSuccess { page ->
_state.update { s ->
s.copy(
items = s.items + page.items,
nextCursor = page.nextCursor,
hasMore = page.hasMore,
isLoading = false
)
}
}
.onFailure { error ->
_state.update { it.copy(isLoading = false, error = error) }
}
}
}
fun refresh() {
_state.value = PaginationState()
loadNext()
}
}
Why This Works
The coordinator is not a ViewModel. It's a plain class that accepts a coroutine scope. The ViewModel creates it, passes its own viewModelScope, and exposes coordinator.state directly.
When you need to switch from a REST source to a local database source for offline support, you swap the PaginatedSource implementation. The ViewModel, the coordinator, and the UI are unchanged.
This is the part most pagination implementations get wrong: they hardcode the data source strategy into the coordinator, which means changing backends requires rewriting the pagination logic too.