Data cache API — useQuery, mutation, QueryClient#
The client cache from janux/query (re-exported by janux/client), plus the route cache policy that gives the server half the same vocabulary. Server state lives here; component state stays in schema. Guides: Data cache & URL state · HTTP cache & revalidation.
import { useQuery, mutation, QueryClient, getQueryClient, hashKey } from 'janux/query';useQuery(bag, id, getOptions, client?)#
The one you'll use. Inside a component, binds a reactive read to the cache, memoized per bag+id so re-renders reuse the same handle.
const products = useQuery(bag, 'catalog', () => ({
queryKey: ['catalog', state.tag],
queryFn: () => fetchCatalog(state.tag),
staleTime: 30_000,
}));
products.data.value; // T | undefined
products.isPending.value; // no data yet
products.isFetching.value; // a request is in flight (including refetches)
products.error.value;
await products.refetch();getOptions is a getter: reading signals inside it is how the key becomes reactive. When the key changes, the handle switches to the new cache entry inside one stable effect — the old subscription is disposed, the new one fetched if stale. That's why a derived wrapping a useQuery has no dependencies and doesn't thrash.
QueryOptions: queryKey (any serializable array), queryFn, staleTime (ms before a read refetches), swr (ms a stale read is still shown for), gcTime (ms an unobserved entry survives), tags (names invalidateTag purges).
staleTime, swr and expiry#
The three states are the same three a route's cachePolicy declares to a CDN, with the same arithmetic:
| Age | State | What a view sees |
|---|---|---|
< staleTime |
fresh | the data; no refetch |
< staleTime + swr |
stale | the data, while it revalidates |
>= staleTime + swr |
expired | isPending again — too old to show |
const products = useQuery(bag, 'catalog', () => ({
queryKey: ['catalog', state.tag],
queryFn: () => fetchCatalog(state.tag),
staleTime: 30_000,
swr: 300_000,
tags: ['catalog'],
}));Without swr there is no expiry: stale data is shown indefinitely while it revalidates, which is what every query did before and still does. Expired data is withheld, not deleted — the entry keeps its updatedAt, so the refetch it triggers is an ordinary revalidation.
query(options, client?)#
The primitive under useQuery, without the per-bag memoization. Use it outside a component — in a helper or a foreign bridge — where you own disposal via the ownership scope. Same QueryHandle shape; options may be an object or a getter.
mutation(options, client?)#
const addItem = mutation({
mutationFn: (vars: { id: string }) => post('/api/cart', vars),
onMutate: (vars) => optimisticallyAdd(vars), // returns ctx
onError: (error, vars, ctx) => rollback(ctx),
onSuccess: (data, vars, ctx) => toast('Added'),
onSettled: () => getQueryClient().invalidateQueries(['cart']),
});
await addItem.mutate({ id: 'sku-1' });
addItem.isPending.value;The four callbacks run in that order; whatever onMutate returns is handed to onError/onSuccess as ctx, which is the optimistic-update pattern with rollback.
QueryClient#
One cache instance. The client runtime creates it; SSR gets a fresh one per request, so server rendering never leaks data between users.
| Method | What it does |
|---|---|
getQuery(options) |
The cache entry for options.queryKey, created if absent |
getQueryData(key) |
Current data for a key, or undefined |
setQueryData(key, data) |
Writes data directly (optimistic updates) |
invalidateQueries(key?) |
Refetches everything matching the key prefix — no key means all |
invalidateTag(tag) |
Refetches every entry carrying tag — the same word revalidateTag uses on the server |
mutate(options, vars) |
Runs a mutation's lifecycle (what mutation() wraps) |
settle(options?) |
Resolves once nothing is in flight — what SSR awaits before dehydrating. Bounded by rounds (waterfall depth, default 10) and timeoutMs (default 5s), so a queryFn that never settles cannot hold a response open |
dehydrate() |
The successful, plain-data entries worth sending to the client |
hydrate(entries) |
Restores entries from the payload, resolving anything awaited |
expect(hashes) |
Marks entries as arriving on the stream, so observers wait instead of fetching |
releaseExpected() |
The response ended: anything still awaited may fetch after all |
invalidateQueries(['cart']) matches by prefix, so it also refreshes ['cart', 'summary']. Failed refetches are swallowed on purpose: invalidation must not reject.
invalidateTag is the client half of one invalidation vocabulary — a mutation drops both sides of the cache with the same string:
await revalidateTag('catalog'); // server: cached pages + the CDN
await getQueryClient().invalidateTag('catalog'); // client: observed queriesdehydrate() deliberately drops anything that is not plain schema-shaped data — a Map, a Set, a Date, a class instance — rather than shipping it broken (JSON.stringify(new Map()) is {}, which would arrive as an empty object). Those entries are refetched on the client. See SSR hydration.
expect/releaseExpected are what the streamed payload drives; an app does not normally call them.
getQueryClient()#
Returns the ambient client, creating it on first use. useQuery prefers bag.ctx.queryClient when present (that's how the per-request SSR client is threaded), then falls back to this.
hashKey(key)#
Turns a QueryKey into the cache's string hash. Object keys are sorted, so ['p', { a: 1, b: 2 }] and ['p', { b: 2, a: 1 }] are the same entry — you don't have to normalize your keys by hand.
hashKey(['catalog', { tag: 'display', page: 1 }]);cachePolicy(def)#
From janux. A named, frozen cache policy — a route's cache export. Guide: HTTP cache & revalidation.
import { cachePolicy } from 'janux';
export const cache = cachePolicy({
name: 'product-page',
scope: 'public',
maxAge: '0s',
sharedMaxAge: '5m',
swr: '1h',
tags: ['catalog', 'product:[id]'],
});| Field | Emits | Default |
|---|---|---|
name |
— (identifies the policy) | required |
scope |
public / private |
private |
maxAge |
max-age |
0 |
sharedMaxAge |
s-maxage |
0 — public policies only |
swr |
stale-while-revalidate |
none — public policies only |
tags |
the cache-tag header | none |
Durations take the framework grammar ('30s', '5m', '1h') or milliseconds. Tag templates fill [param] from the matched route params; a template whose param the request cannot fill is dropped. Declaring sharedMaxAge or swr on a private policy throws — they would never apply.
A route with no cache export answers private, no-store.
cacheHeaders(policy, options?)#
From janux. The headers a policy is worth, for a custom server or an adapter. undefined yields the fail-safe.
cacheHeaders(undefined);
// { 'cache-control': 'private, no-store' }
cacheHeaders(policy, { params: { id: '42' }, tagHeader: 'Surrogate-Key', vary: ['x-janux-navigation'] });revalidateTag(tag) · revalidatePath(path)#
From @janux/server. On-demand revalidation: drops every cached response carrying the tag, or the one at that exact path. Call them wherever the change happens — an api() handler, a webhook, a server intent.
import { revalidatePath, revalidateTag } from '@janux/server';
revalidateTag('product:42');
revalidatePath('/catalog');createResponseCache(config?)#
From @janux/server. The shared response cache createJanuxServer builds for you — exported for a custom server. It stores only scope: 'public', 200, cookie-free responses, reading its windows from the response's own Cache-Control, and stamps x-janux-cache: HIT | STALE | MISS.
const cache = createResponseCache({ tagHeader: 'Cache-Tag', maxEntries: 1000, maxBytes: 2 * 1024 * 1024 });
await cache.handle(req, () => render(req));Related: HTTP cache & revalidation · Data cache & URL state · signal · Ownership