Skip to content

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

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):

val current: StoreResult<ProductDetails> = store.get(id)

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) }
}

updateIfSuccess replaced the old update(key) { } extension. It was renamed to make explicit that the transform runs only when the key's current value is Loaded.

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):

store.updateWith(id, StoreResult.Loaded(productDetails))

All of optimisticUpdate(key), updateIfSuccess(key), submitQuery(key, …) and submitQueryAsync(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