Skip to content

Load Requests

LoadRequest configures how a store loads data: which sources are used and how the loading state is propagated to observers. A request is supplied in exactly two places: per observer via observe(request), and as the store-wide default via setLoadRequest(...) on the builder. Mutating and query calls - invalidate / invalidateAsync and submitQuery / submitQueryAsync - do not take a request; they just trigger a reload, and every observer keeps receiving data according to the request it subscribed with.

A single request can, however, react differently depending on what triggered the reload. It carries two independent keep-content policies:

  • one for reloads triggered by invalidation (invalidate / invalidateAsync / result.invalidate()), configured with keepContentOnLoad / keepContentOnLoadAndError;
  • one for reloads triggered by a query change (submitQuery / submitQueryAsync, or a reactive query flow), configured with keepContentOnQuery / keepContentOnQueryAndError.

This makes it possible, for example, to reload silently in the background on invalidate() while showing a fresh Loading state whenever the query changes - see Keeping Content.

Table of Contents

Predefined Requests

Request Behaviour
LoadRequest.Default Fetch only on demand, when the value is not in the in-memory cache. While loading, observers see StoreResult.Loading. Two identical concurrent requests fetch data only once.
LoadRequest.Silent The same, but existing content stays visible while new content is being loaded - both on invalidation and on query changes (equivalent to builder().keepContentOnLoad().keepContentOnQuery().build()).

LoadRequest.Silent is the natural choice for pull-to-refresh - configure it as the observer's request so that a reload keeps the existing content visible:

// as the store-wide default on the builder:
StoreFactory.simpleStoreBuilder<Data>()
    .setLoadRequest(LoadRequest.Silent)
    .build { /* ... */ }

// or per observer:
fun observe() = store.observe(LoadRequest.Silent)

The reload itself can be triggered straight from the emitted result via result.invalidate() (see Store Results), so the repository/ViewModel usually does not need a dedicated refresh() / tryAgain() function - the UI reloads the same store instance that produced the result:

PullToRefreshBox(
    isRefreshing = result.isBackgroundLoading(),
    onRefresh = result::invalidate,          // no ViewModel function needed
) { /* content */ }

LoadRequest.Default fits a screen that wants the Loading state shown while it reloads (e.g. a "try again" button after a failed load); it is the default when no request is configured. The failed result reloads itself the same way:

Button(onClick = result::invalidate) { Text("Try again") } // Default -> Loading shown

If you do need to trigger a reload without a result at hand, store.invalidateAsync() still works; it is just no longer required for the common pull-to-refresh / try-again flows.

Building a Custom Request

LoadRequest.builder() provides fine-grained control:

val request = LoadRequest.builder()
    .freshMode()                 // optional: skip caches, force a remote fetch
    .keepContentOnLoadAndError() // optional: keep old content during load and on failure
    .build()

// use it as the store-wide default:
StoreFactory.simpleStoreBuilder<Data>()
    .setLoadRequest(request)
    .build { /* ... */ }

// or per observer:
store.observe(request)

Source Modes

The source mode determines where data is loaded from (LoadRequestSource):

Mode Builder call Behaviour
Default (none) Use the in-memory cache if available; fetch only when there is no cached value
Fresh freshMode() Ignore all cached values and fetch from the remote source
Offline offlineMode() Use only cached values; never hit the remote source. If no cached data exists, NoCachedDataException is emitted as a StoreResult.Failed

Keeping Content

The keep-content options control what observers see while a new value is being loaded. They come in two families, one per reload trigger:

Trigger Keep loaded content Keep content even on failure
Invalidation (invalidate / invalidateAsync / result.invalidate()) keepContentOnLoad() keepContentOnLoadAndError()
Query change (submitQuery / submitQueryAsync / query flow) keepContentOnQuery() keepContentOnQueryAndError()
  • keepContentOnLoad() / keepContentOnQuery() - the previously loaded content stays in all observed flows while the new content is loading. If the load fails, the failure replaces the content.
  • keepContentOnLoadAndError() / keepContentOnQueryAndError() - the previous content also survives a failed load; the UI keeps showing the old data instead of an error screen.

Each trigger is configured independently. Without a matching option for a given trigger, observers see StoreResult.Loading for the duration of that load. So keepContentOnLoad() alone keeps content silently on invalidation but still shows Loading when the query changes, and vice versa. Call one option from each family to cover both triggers (that is what LoadRequest.Silent does):

// silent background reload on invalidate(), but a fresh Loading state on query change
val request = LoadRequest.builder()
    .keepContentOnLoad()
    .build()

// keep content on both triggers (same as LoadRequest.Silent)
val request = LoadRequest.builder()
    .keepContentOnLoad()
    .keepContentOnQuery()
    .build()

// keep content only while the query changes; show Loading on an explicit invalidate()
val request = LoadRequest.builder()
    .keepContentOnQuery()
    .build()

Each option accepts an optional Boolean flag (replaceErrorsOnReload for the load family, replaceErrorsOnQuery for the query family, both defaulting to true) that controls what happens when the current state is an error and that reload starts:

  • true (default) - a current error is replaced by a loading state on reload, i.e. content is kept silently only while it is actually loaded; a stale error is not.
  • false - a current error is also kept silently during the reload (the previous error stays visible with a background-load indicator instead of switching to StoreResult.Loading).
// keep even a previous error visible while reloading after an invalidate()
val request = LoadRequest.builder().keepContentOnLoad(replaceErrorsOnReload = false).build()

// keep a previous error visible while the query changes
val request = LoadRequest.builder().keepContentOnQuery(replaceErrorsOnQuery = false).build()

Note. The keep-content and source-mode options are order-independent and each family may be set at most once - the builder's fluent type guides you so that, for example, keepContentOnLoad() cannot be followed by another keepContentOnLoad*() call. keepContentOnQuery*() only affects stores that actually have a query (SimpleQueryStore, KeyedQueryStore, PagedQueryStore); on stores without a query it is simply inert.

Observing Background Loads

When content is kept on load, the reload happens "behind" the visible data. Its progress is reported via metadata instead of a state change:

// completed result + background reload in progress:
val isRefreshing = result.isBackgroundLoading()

// raw access:
val state: BackgroundLoadState = result.backgroundLoadState

This is how a pull-to-refresh spinner is typically driven - the spinner reads isBackgroundLoading() and the refresh gesture reloads the result directly:

PullToRefreshBox(
    isRefreshing = result.isBackgroundLoading(),
    onRefresh = result::invalidate,
) { /* content */ }

Where Load Requests Are Accepted

A LoadRequest is accepted in only two places:

Location Signature Notes
observe observe(request: LoadRequest? = null) The request this observer subscribes with; null uses the store default
Builder setLoadRequest setLoadRequest(LoadRequest) / setLoadRequest(Flow<LoadRequest>) The store-wide default request applied when an observer passes null

Passing null to observe (or omitting the parameter) falls back to the store's configured default request, which is LoadRequest.Default unless overridden via setLoadRequest(...) on the store builder. Keyed stores take the key as the first parameter: store.observe(key, request).

The mutating and query operations - invalidate / invalidateAsync and submitQuery / submitQueryAsync (and their keyed variants) - do not accept a LoadRequest.

Reactive default request

setLoadRequest has two overloads:

  • setLoadRequest(loadRequest: LoadRequest) - a fixed default request.
  • setLoadRequest(flow: Flow<LoadRequest>) - a reactive stream whose latest emission becomes the current default. Whenever the flow emits a new request, it becomes the policy applied to every subsequent load, without recreating the store or touching each observe(...) call site.

This overload is the right tool whenever the loading policy depends on state that changes at runtime. Typical use cases:

  • Automatic online/offline switching - fall back to offlineMode() when connectivity drops and back to Default when it returns.
  • A user-controlled "offline mode" flag stored somewhere (DataStore, preferences, a settings table) and toggled from a settings screen - the flag flow is mapped into a LoadRequest, so flipping the toggle immediately changes how the store loads.
  • A "data saver" / metered-connection setting that prefers cached data, or a battery-saver mode that avoids fresh fetches.

Because the flow drives the default request, the change applies to reloads triggered later (invalidate / invalidateAsync, result.invalidate()) and to new observers - no request needs to be passed at those call sites.

Automatic switching driven by connectivity:

StoreFactory.simpleStoreBuilder<Data>()
    .setLoadRequest(
        connectivity.isOnline.map { online ->
            if (online) LoadRequest.Default
            else LoadRequest.builder().offlineMode().build()
        }
    )
    .build { /* ... */ }

A user-controlled offline flag persisted in a settings store - toggling it in the UI changes the store's loading policy on the fly:

class ArticlesRepository(
    storeFactory: StoreFactory,
    settings: SettingsStore,               // exposes the persisted flag as a Flow
    articlesDataSource: ArticlesDataSource,
) {

    private val store = storeFactory.simpleStoreBuilder<List<Article>>()
        .addSuspendingLocalStorage()       // so offlineMode() has cached data to serve
        .setLoadRequest(
            settings.offlineModeEnabled.map { offline ->   // Flow<Boolean> from DataStore
                if (offline) LoadRequest.builder().offlineMode().build()
                else LoadRequest.Default
            }
        )
        .build(articlesDataSource)

    fun getArticles(): Flow<StoreResult<List<Article>>> = store.observe()
}

Why invalidation takes no request

A store (or a single key) may have several observers at once, each subscribed with its own request - one fresh, one keeping content, another offline. Invalidation therefore does not carry a request: it simply triggers a reload, and every observer keeps receiving data according to the request it subscribed with via observe(...) (or the store default). If invalidation carried a request it would override each observer's own policy, which is why the request lives on observe and the builder instead.