Paged Store¶
PagedStore<T> loads data in chunks (pages) and merges them into a single
List<T> that can be rendered in lazy containers with endless scrolling.
Typical data managed by a paged store: news feed, posts, gallery, search
results.
Table of Contents¶
- Creating a Paged Store
- PagedList
- Total Item Count
- Triggering Next-Page Loads
- Next-Page State
- Reloading and Pull-to-Refresh
- Queries
- Local Storage
- Custom Loaders
- Keyed Paged Stores
- Updating Items
- Full Example
- API Summary
Creating a Paged Store¶
pagedStoreBuilder requires two arguments:
initialKey- the key of the first page (page index, cursor string, limit/offset object, etc.)itemId- a function returning a unique ID per item, used to deduplicate items that appear in more than one page
The onFetch function receives a page key and returns a
PagedList:
class PhotoRepository(
private val dataSource: PhotoDataSource,
) {
private val store = StoreFactory.pagedStoreBuilder<Int, Photo>(
initialKey = 0,
itemId = Photo::id,
).build(
onFetch = { pageKey ->
val page = dataSource.fetchPage(pageKey)
PagedList(items = page.photos, nextKey = page.nextKey)
}
)
fun getPhotos(): Flow<StoreResult<List<Photo>>> = store.observe()
}
In addition to the base setInMemoryCacheTimeout / setCoroutineContext /
setLoadRequest / setLoaderDecorator options (a loader decorator wraps
each page load), paged builders provide setFetchDistance:
private val store = StoreFactory.pagedStoreBuilder<Int, Photo>(0, Photo::id)
.setFetchDistance(10) // default: 20
.build(onFetch = dataSource::fetchPage)
The fetch distance defines how close to the end of the loaded list the user must scroll before the next page starts loading.
PagedList¶
PagedList<PageKey, T> represents one fetched chunk of data:
public data class PagedList<PageKey : Any, T : Any>(
val items: List<T>,
val nextKey: PageKey?,
val metadata: ContainerMetadata = EmptyMetadata,
)
items- the items of the pagenextKey- the key of the next page, ornullwhen there are no more pagesmetadata- optional metadata attached to the page; it is merged into the metadata of the emittedStoreResult(see Total Item Count)
The store concatenates the items of all loaded pages and emits them as a
single List<T> inside StoreResult.Loaded.
Total Item Count¶
Many paginated APIs return the total number of items along with each page.
Pass it via the totalCount constructor of PagedList:
onFetch = { pageKey ->
val page = dataSource.fetchPage(pageKey)
PagedList(items = page.photos, nextKey = page.nextKey, totalCount = page.total)
}
This is a shortcut for attaching TotalPagedItemsCountMetadata to the page.
Read it back from the emitted result via the totalPagedItemsCount extension
property on StoreResult (it returns -1 when no total count was provided, e.g.
for non-paged results):
This is a convenience shortcut for result.metadata.totalPagedItemsCount; both
return the same value.
When pages report different totals, the value from the most recently loaded
page wins. More generally, any ContainerMetadata attached to a page is
propagated to the merged result, so you can surface arbitrary per-page
information (see Container metadata).
Triggering Next-Page Loads¶
The store does not know which items are currently visible, so the UI must
report rendered items via onItemRendered(index). When the rendered index
gets within the fetch distance of the end of the loaded list, the store
loads the next page automatically:
LazyColumn {
itemsIndexed(photos, key = { _, photo -> photo.id }) { index, photo ->
LaunchedEffect(index) {
viewModel.onItemRendered(index) // forwards to store.onItemRendered(index)
}
PhotoCard(photo)
}
}
Pages are appended to the existing list, and observers receive the updated merged list with every loaded page.
Next-Page State¶
While the merged list is exposed through the regular StoreResult, the
status of the next page load is published via metadata. Use the
nextPageState extension property to read it:
| Value | Meaning |
|---|---|
PageState.Idle |
No next-page load in progress (or no more pages) |
PageState.Pending |
The next page is being loaded |
PageState.Error |
The next page failed to load; call retry() to try again |
Render it as a list footer:
LazyColumn {
itemsIndexed(books, key = { _, book -> book.id }) { index, book ->
LaunchedEffect(index) { viewModel.onItemRendered(index) }
BookCard(book)
}
item {
when (val pageState = result.nextPageState) {
PageState.Idle -> Unit
PageState.Pending -> CircularProgressIndicator()
is PageState.Error -> OutlinedButton(onClick = pageState.retry) {
Text("Retry")
}
}
}
}
Note the difference between the two error channels:
- a failed initial load emits
StoreResult.Failed- render a full-screen error - a failed next-page load keeps the already loaded items in
StoreResult.Loadedand reports the failure vianextPageState- render a footer with a retry button
Reloading and Pull-to-Refresh¶
invalidate / invalidateAsync reset the pagination and reload from the
initial key. The emitted result can also do this itself via
result.invalidate(), so a dedicated refresh() / tryAgain() function is
usually unnecessary:
// reload from a Failed result, straight from the UI:
Button(onClick = result::invalidate) { Text("Try again") }
invalidate / invalidateAsync (and submitQuery / submitQueryAsync on a
PagedQueryStore), as well as the reference-free result.invalidate(...), accept
an optional metadata: ContainerMetadata merged into the emitted result — see
Attaching custom metadata to a reload or query
(including the ContainerMetadata.OneShot marker).
Whether the reload shows the Loading state or keeps the current items
visible is decided by the observer's request, not by the invalidation call.
To keep items on screen during a pull-to-refresh, configure
LoadRequest.Silent as the store's default request:
private val store = StoreFactory.pagedStoreBuilder<Int, Photo>(0, Photo::id)
.setLoadRequest(LoadRequest.Silent) // keep content visible while reloading
.build(onFetch = dataSource::fetchPage)
With LoadRequest.Silent, the refresh progress is observable via
result.isBackgroundLoading():
PullToRefreshBox(
isRefreshing = result.isBackgroundLoading(),
onRefresh = result::invalidate,
) { /* LazyColumn */ }
Queries¶
withQuery turns a PagedStore<T> into a PagedQueryStore<Q, T>. The
onFetch function receives both the query and the page key, and
submitting a new query resets the pagination and re-fetches from the
initial key:
private val store = StoreFactory.pagedStoreBuilder<Int, Photo>(0, Photo::id)
.withQuery<Set<PhotoCategory>>(initialQuery = PhotoCategory.entries.toSet())
.build(
onFetch = { categories, pageKey -> dataSource.fetchPage(categories, pageKey) }
)
fun getSelectedCategories(): StateFlow<Set<PhotoCategory>> = store.queryFlow
fun toggleCategory(category: PhotoCategory) {
val current = store.queryFlow.value
val updated = if (category in current) current - category else current + category
store.submitQueryAsync(updated)
}
As with simple stores, withQuery accepts an optional debounceMillis
parameter for search-as-you-type scenarios. submitQuery /
submitQueryAsync take no LoadRequest; submitting a query keeps the
previous results visible while the new query loads.
External query flow¶
Instead of driving the query with submitQuery, pass a reactive query stream to
withQuery as a lambda returning a Flow. The store follows that flow - a new
query resets pagination and reloads from the first page - and the result is a
plain PagedStore<T> with no query API:
private val store = StoreFactory
.pagedStoreBuilder<Int, Photo>(initialKey = 0, itemId = Photo::id)
.withQuery(debounceMillis = 300) { categoryFilter } // StateFlow<Set<PhotoCategory>>
.build { query, pageKey -> photoDataSource.fetchPage(query, pageKey) } // -> PagedStore<Photo>
For a StateFlow the initial query is derived from .value; for a plain Flow
supply an initialQuery for the immediate first load
(withQuery(initialQuery) { flow }).
Local Storage¶
Paged stores support the same local storage modes as other stores. The
storage callbacks operate per page - they receive the page key and a
PagedList:
private val store = StoreFactory.pagedStoreBuilder<Int, Article>(0, Article::id)
.addSuspendingLocalStorage()
.build(
onFetch = remoteArticlesSource::fetchArticles,
onSaveToStorage = { pageKey, pagedList ->
localArticlesSource.saveLocalArticles(pageKey, pagedList)
},
onLoadFromStorage = { pageKey ->
localArticlesSource.fetchLocalArticles(pageKey) // PagedList<Int, Article>?
},
)
Each page is first read from the local storage (and shown immediately if present), then refreshed from the remote source and saved back.
When a query is configured, the storage callbacks additionally receive the query:
private val store = StoreFactory.pagedStoreBuilder<Int, Item>(0, Item::id)
.withQuery(initialQuery = "")
.addSuspendingLocalStorage()
.build(
onFetch = { query, pageKey -> api.fetchPage(query, pageKey) },
onSaveToStorage = { query, pageKey, pagedList -> dao.save(query, pageKey, pagedList) },
onLoadFromStorage = { query, pageKey -> dao.load(query, pageKey) },
)
Contract-based build overloads are available as well: PagedContract,
PagedSuspendingContract and their Query variants.
Custom Loaders¶
When you need to emit more than one value per page load - for example a
cached page first, then a fresh one - without wiring up a full local-storage
layer, use buildCustom instead of build. On a paged builder the block
runs with a PageEmitter receiver and is given the page key; call
emitPage(...) for every value you want to publish and emitNextKey(...)
to advance the pagination:
private val store = StoreFactory.pagedStoreBuilder<Int, Photo>(0, Photo::id)
.buildCustom { pageKey ->
cache.peekPage(pageKey)?.let { emitPage(it.photos) } // show cached page immediately
val fresh = dataSource.fetchPage(pageKey)
emitPage(fresh.photos) // then the fresh page
emitNextKey(fresh.nextKey) // key of the next page (null = no more pages)
}
buildCustom is available on all simple and paged builder variants; it is
the custom loader for store types that emit values manually without a local
storage attached.
Keyed Paged Stores¶
Call .withKeys<Key>() on a paged builder to get a PagedKeyedStore<Key, T>
with independent pagination per key (onItemRendered(key, index)); add
.withQuery(...) for a PagedKeyedQueryStore<Key, Q, T>:
private val store = StoreFactory.pagedStoreBuilder<Int, Photo>(0, Photo::id)
.withKeys<AlbumId>()
.build(onFetch = { albumId, pageKey -> dataSource.fetchPage(albumId, pageKey) })
See Keyed Store for the keyed store API.
Updating Items¶
optimisticUpdate operates on the whole merged list, so per-item updates
work the same way as in simple stores - replace the item and emit the new
list:
suspend fun toggleLike(product: Product) {
store.optimisticUpdate { oldList ->
val index = oldList.indexOfFirst { it.id == product.id }
if (index != -1) {
val newList = oldList.toMutableList().apply {
set(index, product.copy(isLiked = !product.isLiked))
}
emit(newList) // shown immediately; reverted if toggleLike fails
}
dataSource.toggleLike(product)
}
}
get() returns the latest merged list result synchronously, and
updateWith replaces the cached result entirely (including Loading /
Failed states):
val current: StoreResult<List<Product>> = store.get()
store.updateWith(StoreResult.Loaded(products))
Full Example¶
A repository and screen combining pagination, pull-to-refresh and next-page error handling:
class BookRepository(
private val dataSource: BookDataSource,
) {
private val store = StoreFactory.pagedStoreBuilder<Int, Book>(
initialKey = 0,
itemId = Book::id,
)
.setLoadRequest(LoadRequest.Silent) // keep items visible while reloading
.build(onFetch = dataSource::fetchPage)
fun getBooks(): Flow<StoreResult<List<Book>>> = store.observe()
fun onItemRendered(index: Int) = store.onItemRendered(index)
// no refresh()/tryAgain() needed - the UI reloads via result.invalidate()
}
when (val result = state) {
StoreResult.Loading -> CircularProgressIndicator()
is StoreResult.Failed -> ErrorScreen(onTryAgain = result::invalidate)
is StoreResult.Loaded -> {
PullToRefreshBox(
isRefreshing = result.isBackgroundLoading(),
onRefresh = result::invalidate,
) {
LazyColumn {
itemsIndexed(result.value, key = { _, book -> book.id }) { index, book ->
LaunchedEffect(index) { viewModel.onItemRendered(index) }
BookCard(book)
}
item { NextPageFooter(pageState = result.nextPageState) }
}
}
}
}
API Summary¶
| Member | Description |
|---|---|
observe(request = null) |
Observe the merged list as Flow<StoreResult<List<T>>>; null uses the configured default request |
get() |
Read the latest merged-list StoreResult synchronously |
onItemRendered(index) |
Report a rendered item; may trigger the next-page load |
invalidate(metadata?) / invalidateAsync(metadata?) |
Reset pagination and reload; observers keep the request they subscribed with; optional metadata merged into the emitted result |
optimisticUpdate { } |
Update the merged list in the cache |
updateWith(storeResult) |
Replace the cached result with any StoreResult |
whenActive { } |
Run a block while the store has observers |
queryFlow, submitQuery(query, metadata?), submitQueryAsync(query, metadata?) |
Query support (PagedQueryStore only); optional metadata merged into the emitted result |
result.nextPageState |
Idle / Pending / Error state of the next-page load |
result.totalPagedItemsCount |
Total item count reported via PagedList(totalCount = …) (-1 if unknown); shortcut for result.metadata.totalPagedItemsCount |