Paging 3 in Kotlin Multiplatform — PagingSource, Pager, PagingData, and collectAsLazyPagingItems for cursor-based and offset-based list loading. Covers layer placement (PagingSource in :data, Pager in :presenter, LazyPagingItems in :ui), load-state handling, RemoteMediator for network+DB, Koin wiring, and testing PagingSource in isolation. Keeps PagingData out of UiState.
Scanned 9/2/2026
Install to Claude Code
npx -y skills add ronjunevaldoz/kmp-agent-skills --skill kmp-paging --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Kmp Paging?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/ronjunevaldoz-kmp-paging)More formats (shields.io, HTML) on the badges page.
---
name: kmp-paging
description: >-
Paging 3 in Kotlin Multiplatform — PagingSource, Pager, PagingData, and
collectAsLazyPagingItems for cursor-based and offset-based list loading.
Covers layer placement (PagingSource in :data, Pager in :presenter, LazyPagingItems
in :ui), load-state handling, RemoteMediator for network+DB, Koin wiring, and
testing PagingSource in isolation. Keeps PagingData out of UiState.
license: Apache-2.0
metadata:
author: kmp-agent-skills
last-updated: '2026-06-21'
keywords:
- paging
- Paging 3
- PagingSource
- Pager
- PagingData
- LazyPagingItems
- collectAsLazyPagingItems
- cursor pagination
- offset pagination
- infinite scroll
- load more
- RemoteMediator
- LoadState
- pagingSourceFactory
- cachedIn
---
## When to Use This Skill
Use when:
- A screen needs to load a list in pages from a remote API or local database
- Implementing infinite scroll or "load more" behaviour
- Using cursor-based pagination (next-page token from API) or offset-based (page number)
- Combining network fetch with SQLDelight local cache via `RemoteMediator`
- Migrating a `List<T>` in `UiState` to a paged source
**Trigger keywords:** paging, Paging 3, PagingSource, Pager, PagingData, LazyPagingItems,
collectAsLazyPagingItems, infinite scroll, load more, paginate, next page, cursor pagination,
offset pagination, page token, RemoteMediator, load state, append loading, paged list KMP.
**Freshness rule:** `androidx.paging:paging-common` became multiplatform in 3.3.x —
verify the version in `libs.versions.toml` supports KMP before using it on non-Android
targets. Check the [AndroidX Paging release notes](https://developer.android.com/jetpack/androidx/releases/paging)
when upgrading. `paging-compose` lags one version behind `paging-common` — keep them in sync.
---
## Recommendation First
Put the `Pager` in the `:presenter` layer (ViewModel), not in `:ui`. Call `.cachedIn(viewModelScope)` exactly once — this prevents re-fetching on every recomposition and survives configuration changes.
**Never put `PagingData<T>` inside a `UiState` data class.** `PagingData` is not a plain value — it cannot be copied, compared, or serialized. Keep it as a separate `Flow<PagingData<T>>` property on the ViewModel alongside the regular `StateFlow<UiState>`.
---
## `libs.versions.toml` additions
```toml
[versions]
paging = "3.3.5"
[libraries]
paging-common = { module = "androidx.paging:paging-common", version.ref = "paging" }
paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" }
```
In `commonMain` dependencies:
```kotlin
implementation(libs.paging.common)
```
In `androidMain` / `:ui` module:
```kotlin
implementation(libs.paging.compose)
```
---
## Layer placement
```
:api — FooRepository interface exposes pagingSource(): PagingSource<Key, FooItem>
:data — FooPagingSource implements PagingSource; FooRepositoryImpl returns it
:presenter — Pager(...) created in ViewModel; .flow.cachedIn(viewModelScope)
:ui — collectAsLazyPagingItems(); LazyColumn with load-state slots
```
---
## Cursor-based PagingSource (API returns a next-page token)
```kotlin
// :data — FooPagingSource.kt
class FooPagingSource(
private val api: FooApi,
) : PagingSource<String, FooItem>() {
override suspend fun load(params: LoadParams<String>): LoadResult<String, FooItem> {
return try {
val response = api.getItems(
cursor = params.key, // null on first load
pageSize = params.loadSize,
)
LoadResult.Page(
data = response.items,
prevKey = null, // cursor-based: no backwards paging
nextKey = response.nextCursor?.takeIf { it.isNotBlank() },
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
// cursor-based: no meaningful refresh key
override fun getRefreshKey(state: PagingState<String, FooItem>): String? = null
}
```
## Offset-based PagingSource (API uses page numbers)
```kotlin
// :data — FooPagingSource.kt
private const val STARTING_PAGE = 1
class FooPagingSource(
private val api: FooApi,
) : PagingSource<Int, FooItem>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, FooItem> {
val page = params.key ?: STARTING_PAGE
return try {
val response = api.getItems(page = page, pageSize = params.loadSize)
LoadResult.Page(
data = response.items,
prevKey = if (page == STARTING_PAGE) null else page - 1,
nextKey = if (response.items.isEmpty()) null else page + 1,
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, FooItem>): Int? {
return state.anchorPosition?.let { anchor ->
state.closestPageToPosition(anchor)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchor)?.nextKey?.minus(1)
}
}
}
```
---
## Repository interface and implementation
```kotlin
// :api — FooRepository.kt
interface FooRepository {
fun pagingSource(): PagingSource<String, FooItem> // or Int for offset
}
// :data — FooRepositoryImpl.kt
class FooRepositoryImpl(private val api: FooApi) : FooRepository {
override fun pagingSource() = FooPagingSource(api)
}
```
---
## ViewModel — Pager lives here
```kotlin
// :presenter — FooViewModel.kt
class FooViewModel(private val repository: FooRepository) : ViewModel() {
// Regular MVI state — filters, loading indicators, error banners
private val _state = MutableStateFlow(FooContract.State())
val state: StateFlow<FooContract.State> = _state.asStateFlow()
// Paged data — separate from UiState, never put PagingData inside State
val items: Flow<PagingData<FooItem>> = Pager(
config = PagingConfig(
pageSize = 20,
enablePlaceholders = false,
prefetchDistance = 3,
),
pagingSourceFactory = { repository.pagingSource() }
).flow.cachedIn(viewModelScope) // ← required; prevents re-fetch on recomposition
fun onIntent(intent: FooContract.Intent) {
when (intent) {
is FooContract.Intent.Refresh -> _state.update { it.copy(isRefreshing = true) }
}
}
}
```
---
## UI — `collectAsLazyPagingItems` + load states
```kotlin
// :ui — FooScreen.kt
@Composable
fun FooScreen(viewModel: FooViewModel = koinViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
val items = viewModel.items.collectAsLazyPagingItems()
FooContent(state = state, items = items, onIntent = viewModel::onIntent)
}
@Composable
fun FooContent(
state: FooContract.State,
items: LazyPagingItems<FooItem>,
onIntent: (FooContract.Intent) -> Unit,
) {
AppScaffold(
topBar = { AppTopAppBar(title = "Foo") }
) { paddingValues ->
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
// Initial full-screen load
when (val refresh = items.loadState.refresh) {
is LoadState.Loading -> FullScreenLoader()
is LoadState.Error -> FullScreenError(
message = refresh.error.localizedMessage.orEmpty(),
onRetry = { items.retry() }
)
is LoadState.NotLoading -> Unit
}
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(
count = items.itemCount,
key = items.itemKey { it.id }, // stable keys for animations
) { index ->
val item = items[index]
if (item != null) {
FooItemCard(
item = item,
onClick = { onIntent(FooContract.Intent.SelectItem(item.id)) },
)
} else {
FooItemPlaceholder()
}
}
// Append load state — bottom of list
item {
when (val append = items.loadState.append) {
is LoadState.Loading -> AppendLoader()
is LoadState.Error -> AppendError(
message = append.error.localizedMessage.orEmpty(),
onRetry = { items.retry() }
)
is LoadState.NotLoading -> Unit
}
}
}
}
}
}
```
---
## Koin wiring
```kotlin
// Platform module (androidMain / iosMain / desktopMain)
single<FooRepository> { FooRepositoryImpl(get()) }
// No special Koin wiring needed for PagingSource —
// it is created by the pagingSourceFactory lambda inside the ViewModel.
// ViewModel is registered in the common module as usual:
viewModel { FooViewModel(get()) }
```
---
## RemoteMediator — network + SQLDelight cache
Use `RemoteMediator` when you want to cache pages locally and serve from DB while loading fresh data in the background. It requires SQLDelight (or Room) as the local store.
```kotlin
// :data — FooRemoteMediator.kt
class FooRemoteMediator(
private val api: FooApi,
private val db: FooDatabase,
) : RemoteMediator<Int, FooItem>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, FooItem>
): MediatorResult {
val page = when (loadType) {
LoadType.REFRESH -> STARTING_PAGE
LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
LoadType.APPEND -> {
val lastItem = state.lastItemOrNull()
?: return MediatorResult.Success(endOfPaginationReached = true)
// derive next page from last item or a stored cursor
db.fooQueries.getNextPage().executeAsOneOrNull()?.toInt() ?: STARTING_PAGE
}
}
return try {
val response = api.getItems(page = page, pageSize = state.config.pageSize)
db.transaction {
if (loadType == LoadType.REFRESH) db.fooQueries.deleteAll()
response.items.forEach { db.fooQueries.insert(it.toEntity()) }
db.fooQueries.saveNextPage((page + 1).toString())
}
MediatorResult.Success(endOfPaginationReached = response.items.isEmpty())
} catch (e: Exception) {
MediatorResult.Error(e)
}
}
}
// Updated Pager in ViewModel:
val items: Flow<PagingData<FooItem>> = Pager(
config = PagingConfig(pageSize = 20, enablePlaceholders = false),
remoteMediator = FooRemoteMediator(api, db),
pagingSourceFactory = { db.fooQueries.pagingSource().asPagingSourceFactory()() }
).flow.cachedIn(viewModelScope)
```
---
## Testing PagingSource
```kotlin
class FooPagingSourceTest {
private val fakeApi = FakeFooApi(items = (1..50).map { FooItem(id = "$it") })
@Test
fun `first page loads correctly`() = runTest {
val source = FooPagingSource(fakeApi)
val result = source.load(
PagingSource.LoadParams.Refresh(
key = null,
loadSize = 20,
placeholdersEnabled = false,
)
)
assertTrue(result is LoadResult.Page)
result as LoadResult.Page
assertEquals(20, result.data.size)
assertNotNull(result.nextKey)
assertNull(result.prevKey)
}
@Test
fun `last page sets nextKey to null`() = runTest {
val source = FooPagingSource(FakeFooApi(items = (1..5).map { FooItem(id = "$it") }))
val result = source.load(
PagingSource.LoadParams.Refresh(key = null, loadSize = 20, placeholdersEnabled = false)
) as LoadResult.Page
assertNull(result.nextKey)
}
@Test
fun `api error returns LoadResult Error`() = runTest {
val source = FooPagingSource(FakeFooApi(shouldThrow = true))
val result = source.load(
PagingSource.LoadParams.Refresh(key = null, loadSize = 20, placeholdersEnabled = false)
)
assertTrue(result is LoadResult.Error)
}
}
```
---
## Common Anti-Patterns
- **`PagingData` inside `UiState`** — `PagingData` is not a plain value; it cannot be
copied or compared. Keep it as a separate `Flow<PagingData<T>>` on the ViewModel. Using
it inside a `data class State(val items: PagingData<T>)` compiles but breaks MVI state diffing.
- **No `.cachedIn(viewModelScope)`** — without caching, the `Pager` re-fetches from page 1
on every recomposition or navigation event. Always chain `.cachedIn(viewModelScope)`.
- **Creating `Pager` inside a composable** — `Pager` must be created at the ViewModel level
so it survives recomposition. A `Pager` in a composable creates a new one on every recompose.
- **Offset pagination when API provides cursors** — offset requests can return duplicates or
miss items if the server list changes between pages; use the cursor the API provides.
- **Missing `key` lambda in `items(...)`** — without stable keys, Compose cannot animate
insertions/removals and focus is lost on page appends. Always provide `key = items.itemKey { it.id }`.
- **Ignoring `LoadState.refresh`** — the first load goes through `refresh`, not `append`.
A screen that only handles `append` loading shows a blank list with no spinner on initial load.
- **Not handling `LoadState.Error` on refresh** — an API error on first load leaves the user
with a blank screen. Always show a retry button when `loadState.refresh is LoadState.Error`.
---
## Related Skills
- `kmp-network-layer` — `FooApi` interface and `safeRequest` wrapper that
`FooPagingSource` calls; network errors map to `LoadResult.Error`
- `kmp-sqldelight-setup` — required for `RemoteMediator`; SQLDelight provides
the local paging source via `Query.asPagingSourceFactory()`
- `kmp-mvi` — ViewModel still follows the MVI contract; `PagingData` flow
lives alongside `StateFlow<UiState>`, not inside it
- `kmp-unit-testing` — `PagingSource.load()` is a suspend function testable
with `runTest`; no special paging test library required for source-level tests
- `kmp-repository-pattern` — `FooRepository` interface exposes
`pagingSource()` as a factory; `FooRepositoryImpl` owns the concrete `FooPagingSource`
---
## Output Style
When implementing paging, respond in this order:
1. **Strategy** — cursor or offset, based on the API contract
2. **TOML additions** — `paging-common` and `paging-compose` versions
3. **PagingSource** — full implementation with all `LoadResult` branches
4. **Repository wiring** — interface method + implementation
5. **ViewModel** — `Pager` with `PagingConfig`, `.cachedIn(viewModelScope)`
6. **UI** — `collectAsLazyPagingItems`, `LazyColumn` with `key`, refresh + append load states
7. **Tests** — first page, last page (nextKey null), and error case
---
## Changelog
| Date | Change |
|---|---|
| 2026-06-21 | Initial release. |
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!