@molecule/app-query
Core interface · query · App (browser) · v1.0.1 · Apache-2.0
Client-side data cache and prefetch core interface for molecule.dev — fetch once, read synchronously, warm ahead of navigation; the cache engine is a bond
npm install @molecule/app-queryHow it works
@molecule/app-query is the query core interface on the app (browser) side: the API your app calls, with no vendor inside.
Choose the implementation by bonding one of its 2 providers: @molecule/app-query-memory, @molecule/app-query-tanstack.
import { getQueryClient, setProvider } from '@molecule/app-query'
import { provider } from '@molecule/app-query-tanstack'
setProvider(provider)
const packageQuery = (name: string) => ({
key: ['package', name],
fetch: (signal: AbortSignal) =>
fetch(`/data/packages/${name}.json`, { signal }).then((r) => r.json()),
})
const client = getQueryClient()
client.prefetch(packageQuery('api-auth')) // on hover: warm it
const doc = client.get(['package', 'api-auth']) // later, synchronously
await client.fetch(packageQuery('api-auth')) // or wait for it
client.invalidate(['package']) // every package is stale nowProviders (2): @molecule/app-query-memory, @molecule/app-query-tanstack
Works with: @molecule/app-bond
Reference
Client-side query cache core interface for molecule.dev.
One in-memory cache of documents the app fetches — a package page, a
profile, a list — keyed by value, read synchronously, warmed ahead of a
navigation, and observed by components. Bond a provider (e.g.
@molecule/app-query-tanstack) at startup, then use
getQueryClient anywhere; @molecule/app-query-react adds the hooks.
Quick Start
import { getQueryClient, setProvider } from '@molecule/app-query'
import { provider } from '@molecule/app-query-tanstack'
setProvider(provider)
const packageQuery = (name: string) => ({
key: ['package', name],
fetch: (signal: AbortSignal) =>
fetch(`/data/packages/${name}.json`, { signal }).then((r) => r.json()),
})
const client = getQueryClient()
client.prefetch(packageQuery('api-auth')) // on hover: warm it
const doc = client.get(['package', 'api-auth']) // later, synchronously
await client.fetch(packageQuery('api-auth')) // or wait for it
client.invalidate(['package']) // every package is stale now
Type
core
Installation
npm install @molecule/app-query @molecule/app-bond
API
Interfaces
QueryClient
The cache. One per app (see getQueryClient), shared by every page.
interface QueryClient {
/**
* The cached document, synchronously, whether fresh or stale. Never fetches.
*
* @param key - The document's key.
* @returns The document, or `undefined` when nothing is cached.
*/
get<T>(key: QueryKey): T | undefined
/**
* The document's full state, synchronously.
*
* @param key - The document's key.
* @returns The state; `idle` with no data for an unknown key.
*/
getState<T>(key: QueryKey): QueryState<T>
/**
* The document: resolved at once when cached and fresh, shared with a
* fetch already in flight, otherwise fetched and cached. Rejects when the
* fetch fails; a failure is never cached, so the next call tries again.
*
* @param options - Key, fetch, freshness.
* @returns The document.
*/
fetch<T>(options: QueryOptions<T>): Promise<T>
/**
* Warms the cache for a document likely to be needed next. Never throws
* and never rejects; does nothing when the document is cached and fresh
* or already being fetched.
*
* @param options - Key, fetch, freshness.
*/
prefetch<T>(options: QueryOptions<T>): void
/**
* Seeds the cache, for example from a JSON island the server rendered.
*
* @param key - The document's key.
* @param data - The document.
*/
set<T>(key: QueryKey, data: T): void
/**
* Marks the document, and every document whose key starts with this key,
* as stale: the next `fetch` or `subscribe` refetches it.
*
* @param key - A key or a key prefix.
*/
invalidate(key: QueryKey): void
/**
* Observes a document: emits its state now and on every change, starting
* a fetch when the document is absent or stale.
*
* @param options - Key, fetch, freshness.
* @param listener - Called with each state.
* @returns Stops observing.
*/
subscribe<T>(options: QueryOptions<T>, listener: (state: QueryState<T>) => void): () => void
/** Drops every document and every in-flight fetch. */
clear(): void
}
QueryClientConfig
Defaults a client applies to every query that does not say otherwise.
interface QueryClientConfig {
/** Default freshness. Defaults to 5 minutes. */
staleMs?: number
/** Default time an unused document stays in memory. Defaults to 30 minutes. */
gcMs?: number
}
QueryOptions
How one document is fetched and how long it stays fresh.
interface QueryOptions<T> {
/** What identifies the document. */
key: QueryKey
/** Loads the document. The signal aborts when nobody wants the result any more. */
fetch: (signal: AbortSignal) => Promise<T>
/** How long a cached document is fresh (no refetch). Defaults to the client's, then 5 minutes. */
staleMs?: number
/** How long an unused document stays in memory. Defaults to the client's, then 30 minutes. */
gcMs?: number
}
QueryProvider
Query provider interface. All query bonds implement this.
interface QueryProvider {
/** Provider name identifier (e.g. `'tanstack'`, `'memory'`). */
readonly name: string
/**
* Creates a cache.
*
* @param config - Defaults for every query.
* @returns A client.
*/
createClient(config?: QueryClientConfig): QueryClient
}
QueryState
One document's state, as observers see it.
interface QueryState<T> {
/** The document, when it has ever been loaded or set. */
data: T | undefined
/** `idle` before anything happened, `loading` with nothing cached yet, then `success` or `error`. */
status: QueryStatus
/** The last fetch's error, when `status` is `error`. */
error: unknown
/** A fetch is in flight (also while stale data is shown). */
isFetching: boolean
/** When `data` was last written, in milliseconds since the epoch. */
updatedAt: number | undefined
}
Types
QueryKey
A query key: an array of parts, compared by value (['package', 'api-auth']
equals another ['package', 'api-auth']; an object part equals another with
the same entries). A key PREFIXES every longer key that starts with its
parts, which is what QueryClient.invalidate uses.
type QueryKey = readonly unknown[]
QueryStatus
Where a document stands.
type QueryStatus = 'idle' | 'loading' | 'success' | 'error'
Functions
configureQueryClient(config)
Sets the defaults the shared client is created with. Call before the first
getQueryClient; later calls take effect after resetQueryClient.
function configureQueryClient(config: QueryClientConfig | undefined): void
config— Defaults for every query.
getProvider()
Retrieves the bonded query provider, throwing if none is configured.
function getProvider(): QueryProvider
Returns: The bonded query provider.
getQueryClient()
The app's one cache, created by the bonded provider on first use and reused afterwards, so every page reads and warms the same documents.
function getQueryClient(): QueryClient
Returns: The shared client.
hashQueryKey(key)
A stable string for a key: object parts are serialized with their entries
sorted, so { a: 1, b: 2 } and { b: 2, a: 1 } hash the same; undefined
parts hash as null.
function hashQueryKey(key: QueryKey): string
key— The key.
Returns: Its string form.
hasProvider()
Checks whether a query provider is currently bonded.
function hasProvider(): boolean
Returns: true if a query provider is bonded.
keyStartsWith(key, prefix)
Whether key starts with every part of prefix (compared by value).
function keyStartsWith(key: QueryKey, prefix: QueryKey): boolean
key— The longer key.prefix— The shorter key.
Returns: true when prefix prefixes key (a key prefixes itself).
resetQueryClient()
Forgets the shared client (its next use creates a new one). Tests only.
function resetQueryClient(): void
setProvider(provider)
Registers a query provider as the active singleton. Called by bond
packages (e.g. @molecule/app-query-tanstack) during app startup.
function setProvider(provider: QueryProvider): void
provider— The query provider implementation to bond.
shouldPrefetch()
Whether it is reasonable to load something the person has not asked for
yet: false under the browser's data-saver setting or on a 2G-class
connection, true otherwise (including where the browser reports nothing).
function shouldPrefetch(): boolean
Returns: Whether to prefetch.
whenIdle(fn, timeout)
Runs fn when the browser is idle, or after timeout milliseconds at the
latest; where idle callbacks do not exist, after a short delay.
function whenIdle(fn: () => void, timeout?: number): () => void
fn— What to run.timeout— The longest wait, in milliseconds. Defaults to 2000.
Returns: Cancels the run if it has not happened yet.
Available Providers
| Provider | Package |
|---|---|
| In-memory (no persistence) | @molecule/app-query-memory |
| TanStack Query | @molecule/app-query-tanstack |
Injection Notes
Requirements
Peer dependencies:
@molecule/app-bond^1.0.1
Runtime Dependencies
-
@molecule/app-bond -
This is a browser cache, not a store. It holds what was fetched, for as long as it is useful; it is not where app state lives (see
@molecule/app-state) and it never persists. -
Keys are compared by value.
['package', 'api-auth']twice is one document; object parts compare by their entries. Put everything the fetch depends on into the key, or two different requests share one cache slot. -
get()returns stale data too. That is the point — a page paints what it has at once — whilesubscribe()refetches in the background. CheckupdatedAtif freshness matters for a display. -
A failed fetch is never cached, so retrying is one more
fetch().prefetch()swallows failures on purpose: the real navigation reports them. -
Prefetch with intent (pointer enter, focus, touch start) and while idle, and only when
shouldPrefetch()allows: a person on a metered or 2G connection did not ask for the page they hovered. -
Use one client per app (
getQueryClient()); a second client is a second cache that knows nothing about the first.
E2E Tests
Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual screens/flows, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip:
- Opening a detail page after hovering its link shows no loading state (the document was warmed) and the page is the right one.
- Opening the same page twice fetches it once (check the network panel).
- Going back to a page painted earlier paints at once, from memory.
- After the app invalidates a key (an edit, a refresh action), the next view shows the new data.
- With the browser's data-saver on, hovering links fetches nothing.