Keyed Store¶
KeyedStore<Key, T> manages multiple values, one per key, like a map of
simple stores. Each key-value entry has its own
auto-managed lifecycle determined by its observers. Typical data managed
by a keyed store: product details, friend profiles, movie details - any
entity fetched by a unique identifier.
Table of Contents¶
- Creating a Keyed Store
- Per-Key Cache Lifecycle
- Observing and Reloading
- Local Storage
- Local-Only Stores (No Fetcher)
- Updating Cached Data
- Keyed Queries
- Paged Keyed Stores
- Custom Loaders
- Combining a List with Keyed Details
- Synchronizing Stores
- Sharing a Store Between Screens
- API Summary
Creating a Keyed Store¶
The minimal setup requires only an onFetch function that receives the
key:
class CatDetailsRepository(
private val catsDataSource: CatsDataSource,
) {
private val store = StoreFactory.simpleStoreBuilder<CatDetails>().withKeys<Long>()
.build(onFetch = catsDataSource::fetchCatDetails)
fun getCat(id: Long): Flow<StoreResult<CatDetails>> = store.observe(id)
}
A keyed store is created by calling .withKeys<Key>() on a simple (or
paged) builder; there is no dedicated keyedStoreBuilder factory method.
withKeys is available on all simple and paged builder variants and
preserves the configuration applied before it (cache timeout, local
storage choice, query, etc.). The recommended order is: start from
simpleStoreBuilder/pagedStoreBuilder, then .withKeys<Key>(), then
storage/query config, then .build(...).
As with all stores, the builder supports setInMemoryCacheTimeout,
setCoroutineContext and setLoaderDecorator (the decorator wraps the fetch
of every key). setLoadRequest is also available and configures
the default request used when observing a key (both the fixed
setLoadRequest(loadRequest) form and the reactive
setLoadRequest(flow) overload are supported):
private val store = StoreFactory.simpleStoreBuilder<ProductDetails>().withKeys<Long>()
.setInMemoryCacheTimeout(10.seconds)
.setCoroutineContext(Dispatchers.IO)
.build(onFetch = productsDataSource::fetchProductById)
Per-Key Cache Lifecycle¶
Unlike a traditional map, entries are not kept forever:
- An entry is created and loaded when the first observer subscribes to
observe(key)for that key. - All observers of the same key share one cached value.
- When the last observer of a key unsubscribes, the entry is kept for the configured in-memory cache timeout (5 seconds by default) and then removed from the cache.
This means each screen observing observe(productId) gets a shared,
up-to-date value while it is open, and the memory is reclaimed
automatically after the screens are closed.
The set of currently active keys - those that have an observer or are
still within their cache-timeout window - is exposed as
activeKeys: StateFlow<Set<Key>>. It is useful for keeping the loaded
entries in sync with an external source.
Observing and Reloading¶
All key-less operations of a simple store have keyed counterparts:
// observe a single entity
fun getProduct(id: Long): Flow<StoreResult<ProductDetails>> {
return store.observe(id)
}
// force a reload of a single entity
suspend fun refreshProduct(id: Long) {
store.invalidate(id)
}
fun refreshProductAsync(id: Long) {
store.invalidateAsync(id)
}
observe accepts an optional LoadRequest controlling
how the data is loaded (fresh/offline mode, keeping content while
reloading); the argument is nullable and, when null (the default), the
store's configured default request is used. invalidate and
invalidateAsync accept an optional metadata: ContainerMetadata merged
into the emitted result; invalidateAllAsync(metadata?) applies it to
every active key. See Attaching custom metadata to a reload or query
(including the ContainerMetadata.OneShot marker).
For UI-driven reloads (pull-to-refresh, "try again") you often don't need
these per-key functions at all: the emitted StoreResult can reload the key
it came from via result.invalidate() (see
Store Results). Combine it with LoadRequest.Silent to
keep the current content visible while the key reloads.
To read the latest result for a key synchronously (without collecting the
flow), use get(key) (or getOrNull(key) for the unwrapped value):
Local Storage¶
Keyed stores support the same two local storage modes as simple stores; each callback additionally receives the key:
// suspending storage (e.g. DAO with suspend functions)
private val store = StoreFactory.simpleStoreBuilder<Book>().withKeys<BookId>()
.addSuspendingLocalStorage()
.build(
onFetch = { bookId -> api.fetchBook(bookId) },
onSaveToStorage = { bookId, book -> dao.save(bookId, book) },
onLoadFromStorage = { bookId -> dao.load(bookId) }, // returns T?
)
// reactive storage (e.g. Room Flow queries)
private val store = StoreFactory.simpleStoreBuilder<Book>().withKeys<BookId>()
.addReactiveLocalStorage()
.build(
onFetch = { bookId -> api.fetchBook(bookId) },
onSaveToStorage = { bookId, book -> dao.save(bookId, book) },
onObserveStorage = { bookId -> dao.observe(bookId) }, // returns Flow<T?>
)
With reactive storage, any change of the keyed record in the local storage is automatically delivered to the observers of that key.
Contract-based build overloads are available too: SimpleKeyedContract,
SimpleKeyedSuspendingContract, SimpleKeyedReactiveContract (the
reactive contract's observe method is observeLocalStorage(key)).
Local-Only Stores (No Fetcher)¶
When there is no remote source and each key is backed only by a local,
reactive storage, call disableFetcher() and provide just an onObserve
lambda returning the local Flow<T> for a key:
private val store = StoreFactory.simpleStoreBuilder<Book>().withKeys<BookId>()
.disableFetcher()
.build(onObserve = { bookId -> dao.observeBook(bookId) }) // (Key) -> Flow<T>
For each observed key the store subscribes to the flow and exposes its
values as StoreResult: the first emission moves the key from Loading to
Loaded, and every later emission is delivered to the observers of that key.
There is no remote fetch and no save step. A contract overload backed by
SimpleKeyedReactiveNoFetcherContract is available as well:
class BooksDataSource : SimpleKeyedReactiveNoFetcherContract<BookId, Book> {
override fun observe(key: BookId): Flow<Book> = dao.observeBook(key)
}
private val store = StoreFactory.simpleStoreBuilder<Book>().withKeys<BookId>()
.disableFetcher()
.build(BooksDataSource())
The per-key observe flow is expected to emit at least once; if it never emits, that key stays in
StoreResult.Loading.
Updating Cached Data¶
optimisticUpdate(key) works like the simple-store version, scoped to one
key. Emitted values are auto-reverted if the block throws:
suspend fun updateCatName(id: Long, name: String) {
store.optimisticUpdate(id) { oldDetails ->
emit(oldDetails.copy(cat = oldDetails.cat.copy(name = name)))
catsDataSource.updateCatName(id, name) // reverts the cache on failure
}
}
To apply a read-modify-write transform to a key's currently loaded value
without optimistic semantics, use updateIfSuccess(key). It reads the current
value, applies your transform and writes it back; if the key is not currently
holding a loaded value the transform is not invoked:
suspend fun markAsRead(bookId: BookId) {
dao.markAsRead(bookId)
store.updateIfSuccess(bookId) { it.copy(isRead = true) }
}
updateIfSuccessreplaced the oldupdate(key) { }extension. It was renamed to make explicit that the transform runs only when the key's current value isLoaded.
To replace the cached result for a key entirely - including switching to a
Loading or Failed state, or seeding a value without a previous one - use
updateWith(key, storeResult):
All of
optimisticUpdate(key),updateIfSuccess(key),submitQuery(key, …)andsubmitQueryAsync(key, …)operate on a key's in-memory cache, which exists only while that key has at least one active observer (plus the cache timeout after the last observer unsubscribes). Calling them for a key that is not currently observed does nothing - start observing the key (and let it load) first.
Keyed Queries¶
Adding .withQuery(initialQuery) to a keyed builder produces a
KeyedQueryStore<Key, Q, T>, where every key holds its own independent
query. The fetcher receives both the key and the current query for that
key:
private val store = StoreFactory.simpleStoreBuilder<List<Review>>()
.withKeys<ProductId>()
.withQuery(ReviewQuery(sort = Sort.Newest), debounceMillis = 0)
.build(onFetch = { productId, query -> api.fetchReviews(productId, query) })
// observe the current query for a key
fun observeSort(productId: ProductId): StateFlow<ReviewQuery> =
store.observeQueryFlow(productId)
// change the query for a single key (each key is independent)
suspend fun setSort(productId: ProductId, query: ReviewQuery) {
store.submitQuery(productId, query)
}
fun setSortAsync(productId: ProductId, query: ReviewQuery) {
store.submitQueryAsync(productId, query)
}
Submitting a new query for a key reloads only that key. Like
invalidate, submitQuery/submitQueryAsync do not accept a
LoadRequest, but both accept an optional metadata: ContainerMetadata
merged into the emitted result (see
Metadata).
Contract-based overloads are available too:
SimpleKeyedQueryContract, SimpleKeyedQuerySuspendingContract,
SimpleKeyedQueryReactiveContract,
SimpleKeyedQueryReactiveNoFetcherContract.
External query flow¶
Instead of calling submitQuery(key, …), you can let each key follow an
external query Flow. The withQuery lambda receives the key, so every key can
have its own query stream (for example, a per-key filter StateFlow). The
result is a plain KeyedStore<Key, T> with no query API:
private val store = StoreFactory.simpleStoreBuilder<ProductReviews>()
.withKeys<ProductId>()
.withQuery(initialQuery = ReviewQuery(sort = Sort.Newest)) { productId ->
sortPreferences.reviewQueryFor(productId) // Flow<ReviewQuery>
}
.build { productId, query -> api.fetchReviews(productId, query) } // -> KeyedStore<ProductId, ProductReviews>
For a StateFlow source, drop initialQuery - each key's initial query is
derived from that key's StateFlow.value:
withQuery { key -> queryStateFlowFor(key) }. Only the key whose flow emits is
reloaded; other keys keep their current query and cached value. This overload is
available on the remote-only, suspending, reactive and disableFetcher() keyed
builders. See Simple Store for the
overload semantics.
The keyed lambda above (withKeys() first) gives each key its own query
flow. If instead you call the non-keyed withQuery { flow } and then
withKeys(), that single flow is shared by every key - a global query
stream driving all keys at once. Both orderings are supported, and withQuery
can be applied before or after addSuspendingLocalStorage() /
addReactiveLocalStorage() / disableFetcher().
Paged Keyed Stores¶
Calling .withKeys<Key>() on a paged builder produces a
PagedKeyedStore<Key, T>: each key owns an independent, incrementally
paginated list.
private val store = StoreFactory
.pagedStoreBuilder<PageKey, Comment>(
initialKey = PageKey(page = 0),
itemId = { it.id },
)
.withKeys<ArticleId>()
.build(onFetch = { articleId, pageKey -> api.fetchComments(articleId, pageKey) })
fun observeComments(articleId: ArticleId): Flow<StoreResult<List<Comment>>> =
store.observe(articleId)
// request the next page for a key as its items are rendered
fun onCommentRendered(articleId: ArticleId, index: Int) {
store.onItemRendered(articleId, index)
}
Adding .withQuery(...) as well produces a
PagedKeyedQueryStore<Key, Q, T>, combining per-key pagination with a
per-key query. Contract-based overloads include PagedKeyedContract,
PagedKeyedSuspendingContract, PagedKeyedQueryContract, and
PagedKeyedQuerySuspendingContract (paged-keyed suspending contracts use
the method names fetch, saveToLocalStorage, loadFromLocalStorage).
Custom Loaders¶
When a store has no local storage attached but you still need to emit more
than one value per load - for example a cached value first and then a
fresh one - use buildCustom { } instead of build(...). The block runs
per key with an Emitter<T> receiver:
private val store = StoreFactory.simpleStoreBuilder<CatDetails>().withKeys<Long>()
.buildCustom { key ->
// this: Emitter<CatDetails>
emit(catsDataSource.loadCachedCat(key)) // first, the cached value
emit(catsDataSource.fetchCatDetails(key)) // then the fresh one
}
For query stores the block also receives the current query; for paged
builders it receives the page key and exposes a PageEmitter
(emitPage(...), emitNextKey(...)).
Combining a List with Keyed Details¶
A common pattern is a list loaded by a simple store
where every item needs extra data loaded per key. The
storeListFlatMapLatest extension observes the keyed store for every item
of the list and merges the results:
class BasicItemsRepository(
private val dataSource: BasicItemsDataSource,
) {
private val listStore = StoreFactory.simpleStoreBuilder<List<Item>>()
.build(onFetch = dataSource::fetchItems)
private val descriptionStore = StoreFactory.simpleStoreBuilder<String>().withKeys<Long>()
.build(onFetch = dataSource::fetchDescription)
fun getItems(): Flow<StoreResult<List<ListItem>>> {
return listStore
.observe()
.storeListFlatMapLatest(
observer = { item -> descriptionStore.observe(item.id) },
mapper = { item, descriptionResult ->
ListItem(item, descriptionResult.getOrNull())
},
)
}
}
The list is emitted as soon as it is loaded; descriptions arrive
individually (getOrNull() returns null while a description is still
loading) and each arrival re-emits the merged list. See
Store Results for the full set of
transformation extensions.
Synchronizing Stores¶
In master-detail flows, an edit on the details screen should be reflected
in the master list. Combine whenActive with events to synchronize two
independent stores:
// Details: keyed store, publishes an event after each successful update
@Singleton
class CatDetailsRepository(
private val catsDataSource: CatsDataSource,
) : CatEvents {
private val store = StoreFactory.simpleStoreBuilder<CatDetails>().withKeys<Long>()
.build(onFetch = catsDataSource::fetchCatDetails)
private val catEvents = MutableSharedFlow<CatUpdatedEvent>()
override fun observeCatEvents(): Flow<CatUpdatedEvent> = catEvents
suspend fun updateCatName(id: Long, name: String) {
store.optimisticUpdate(id) { old ->
val updated = old.copy(cat = old.cat.copy(name = name))
emit(updated)
catsDataSource.updateCatName(id, name)
catEvents.emit(CatUpdatedEvent(updated.cat))
}
}
}
// Master list: applies events to its own cache while it is active
@Singleton
class CatsRepository(
private val catsDataSource: CatsDataSource,
private val catEvents: CatEvents,
) {
private val store = StoreFactory.simpleStoreBuilder<List<Cat>>()
.build(onFetch = catsDataSource::fetchCats)
.whenActive {
catEvents.observeCatEvents().collect { event ->
optimisticUpdate { oldList ->
emit(oldList.map { if (it.id == event.cat.id) event.cat else it })
}
}
}
fun getCats(): Flow<StoreResult<List<Cat>>> = store.observe()
}
The whenActive block is started when the list store gets its first
observer and cancelled when the cache is released, so the subscription does
not outlive the cached data.
Sharing a Store Between Screens¶
Because all observers of a key share one cached value, a singleton repository with a keyed store naturally keeps multiple screens consistent. In the demo's shopping example, the cart repository exposes derived flows built on top of the same store, and the products / details screens combine them with their own stores:
@Singleton
class CartRepository(
private val cartDataSource: CartDataSource,
private val productDetailsObserver: ProductDetailsObserver,
) {
private val store = StoreFactory.simpleStoreBuilder<List<CartItem>>()
.build(onFetch = cartDataSource::fetchCart)
fun getMinimalCart(): Flow<StoreResult<List<CartItem>>> = store.observe()
fun isInCart(productId: Long): Flow<StoreResult<Boolean>> {
return getMinimalCart().storeMap { cart ->
cart.any { it.productId == productId }
}
}
suspend fun addToCart(product: Product) {
store.optimisticUpdate { cart ->
emit(cart + CartItem(product.id, quantity = 1))
cartDataSource.addToCart(product)
}
}
}
// Product details screen: details + cart membership in one state
val stateFlow: StateFlow<StoreResult<State>> = combineStores(
productDetailsRepository.getProductById(productId),
cartRepository.isInCart(productId),
::State,
).stateIn(viewModelScope, SharingStarted.Lazily, StoreResult.Loading)
An optimistic addToCart on one screen is instantly visible on every
other screen observing the cart, with no manual propagation.
API Summary¶
Keyed stores are created with simpleStoreBuilder<T>().withKeys<Key>()
(or pagedStoreBuilder<PageKey, T>(...).withKeys<Key>()); there is no
keyedStoreBuilder factory. Adding .withQuery(...) yields a
KeyedQueryStore (or PagedKeyedQueryStore).
| Member | Description |
|---|---|
observe(key, request? = null) |
Fetch and observe the value for key (request is optional) |
get(key) / getOrNull(key) |
Read the latest result (or unwrapped value) for key |
failureOrNull(key) |
Read the latest failure for key, or null |
invalidate(key, metadata?) / invalidateAsync(key, metadata?) |
Force a reload of one key; optional metadata merged into the emitted result |
invalidateAllAsync(metadata?) |
Reload every active key; optional metadata merged into each emitted result |
optimisticUpdate(key) { } |
Update one key's cache ahead of the real update, with auto-revert |
updateIfSuccess(key) { old -> new } |
Read-modify-write one key; no-op unless its value is Loaded |
updateWith(key, storeResult) |
Replace the cached result for key with any StoreResult |
activeKeys |
StateFlow<Set<Key>> of currently active keys |
whenActive { } |
Run a block while the store has observers (any key) |
observeQueryFlow(key) |
Keyed-query: observe the current query for key |
submitQuery(key, query, metadata?) / submitQueryAsync(key, query, metadata?) |
Keyed-query: change the query for one key (no request); optional metadata merged into the emitted result |
onItemRendered(key, index) |
Paged-keyed: request the next page for key as items are shown |