cached

cached(url, opts?) is a layered fetch wrapper: concurrent in-flight dedup, the browser Cache API with per-entry TTL, then a real network request. It returns a Promise — drop it into derived and you get a Suspense-friendly reactive value with zero retry bookkeeping.

Cache entries are stamped with an internal x-cached-at header, so TTL works without a sidecar index — the Cache API itself is the source of truth. Successful results are dropped from the in-flight map so a later call past the TTL can refresh; failed fetches are dropped too so they can be retried.

Arguments

Argument Type Description
url string URL to fetch and key the cache by.
opts CachedOptions Optional. TTL, cache bucket, and response parser — see below.

CachedOptions

Option Type Default Description
ttl number Infinity Milliseconds a Cache API entry stays fresh. After expiry the next call re-fetches.
cacheName string 'pota-cache-v1' Cache API bucket to read/write. Bump it to isolate old entries on a breaking change.
parse (r: Response) => unknown r => r.json() Applied to the cached or freshly-fetched Response to produce the resolved value.

Returns: a Promise resolving to the parsed value.

How it works

Layers are consulted in order:

  1. In-flight dedup. Concurrent callers for the same URL share one Promise; only one request goes out.
  2. Cache API. A matching entry within the TTL window is parsed and returned without touching the network. Entries carry an x-cached-at stamp, so freshness is derived from the entry itself.
  3. Network fetch. On a miss (or expiry) the response is fetched, stamped, and stored. Persistence is best-effort — a failed put is swallowed — and the original response is handed to parse.

Examples

Reactive fetch with derived

Wire cached into derived so the URL drives the request. Both layers dedup, so flipping between ids never fires the same request twice.

import { derived, render, signal } from 'pota'
import { cached } from 'pota/use/cached'

function App() {
	const id = signal(1)

	const post = derived(
		() => `https://jsonplaceholder.typicode.com/posts/${id.read()}`,
		url => cached(url),
	)

	return (
		<div>
			<button on:click={() => id.update(n => n + 1)}>next</button>
			<button on:click={() => id.write(1)}>reset</button>
			<h3>post #{id.read}</h3>
			<p>{() => post()?.title ?? 'loading…'}</p>
		</div>
	)
}

render(App)

TTL

Within the TTL window, repeat calls come from the Cache API without touching the network; the first call past expiry fetches once and re-stamps the entry for everyone.

import { render, signal } from 'pota'
import { cached } from 'pota/use/cached'

const URL = 'https://jsonplaceholder.typicode.com/posts/1'

function App() {
	const status = signal('idle')

	async function load() {
		status.write('fetching…')
		// 5s TTL — within the window, calls hit the Cache API
		const post = await cached<{ title: string }>(URL, { ttl: 5_000 })
		status.write(`got "${post.title.slice(0, 40)}…"`)
	}

	return (
		<div>
			<button on:click={load}>fetch</button>
			<p>{status.read}</p>
			<p>
				<small>
					click rapidly: subsequent calls within 5s come from the
					cache without touching the network
				</small>
			</p>
		</div>
	)
}

render(App)

Non-JSON responses

Provide your own parse to pull text, blobs, or anything else off the Response. The cache still stores the bytes; the parser only runs on the value you receive.

import { derived, render, signal } from 'pota'
import { cached } from 'pota/use/cached'

function App() {
	const id = signal(1)

	// override the default JSON parser to fetch a plain-text view
	const body = derived(
		() => `https://jsonplaceholder.typicode.com/posts/${id.read()}`,
		url => cached(url, { parse: r => r.text() }),
	)

	return (
		<div>
			<button on:click={() => id.update(n => n + 1)}>next</button>
			<pre style={{ 'white-space': 'pre-wrap' }}>
				{() => body() ?? 'loading…'}
			</pre>
		</div>
	)
}

render(App)