Caching, revalidation, retries — one keyword each
Async data loading loaded a customer with resource … signal. Real
apps want more from that fetch: cache so revisits are instant, stale-while-
revalidate so you show cached data and refresh in the background, retry so a
flaky network self-heals. In React, each of those is a careful chunk of code (and
a class of bugs). In Reactra, each is a word on the resource.
React — hand-rolling cache + SWR + retry
Section titled “React — hand-rolling cache + SWR + retry”const cache = new Map<string, { value: Customer; at: number }>()const TTL = 60_000
async function fetchWithRetry(id: string, signal: AbortSignal, tries = 3): Promise<Customer> { let lastErr: unknown for (let i = 0; i < tries; i++) { try { return await fetchCustomer(id, signal) } catch (e) { lastErr = e if (signal.aborted) throw e await new Promise((r) => setTimeout(r, 2 ** i * 200)) // backoff } } throw lastErr}
function useCustomer(id: string) { const [data, setData] = useState<Customer | null>(() => cache.get(id)?.value ?? null) const [isPending, setIsPending] = useState(!cache.get(id)) const [error, setError] = useState<unknown>(null)
useEffect(() => { const controller = new AbortController() const entry = cache.get(id) const fresh = entry && Date.now() - entry.at < TTL if (entry) setData(entry.value) // SWR: show stale immediately if (!fresh) { // …and revalidate if missing/expired setIsPending(!entry) fetchWithRetry(id, controller.signal) .then((value) => { if (controller.signal.aborted) return cache.set(id, { value, at: Date.now() }) setData(value); setIsPending(false) }) .catch((e) => { if (!controller.signal.aborted) { setError(e); setIsPending(false) } }) } return () => controller.abort() }, [id])
return { data, isPending, error }}…plus you still wire the loading/error branches in the component. And every line is a place to get cache invalidation, the TTL check, or the retry backoff subtly wrong.
Reactra — the modifiers are the policy
Section titled “Reactra — the modifiers are the policy”export component CustomerCard { param id: string
// signal = cancellation · swr = cache + background revalidate · retry = auto-retry on failure resource customer(id) signal swr retry => fetchCustomer(id, signal)
view { await(customer) { <Card c={customer.data} /> } pending { <Spinner /> } error(e) { <ErrorText error={e} /> } }}import { Suspense, use, useEffect } from "react"import { ErrorBoundary, useResource } from "@reactra/resource"import { useRoute } from "@reactra/router"
export const CustomerCard = () => { const __route = useRoute() const id = __route.params.id const customer = useResource(id, (id, { signal }) => (fetchCustomer(id, signal)), { resourceName: "customer", cache: { staleWhileRevalidate: true, ttlMs: 60_000 }, retry: 3 }) useEffect(() => { const h = globalThis.__REACTRA_TEST__; if (h) h.update("CustomerCard", { customer }) }) return (<> <ErrorBoundary fallback={(e) => <><ErrorText error={e} /></>}><Suspense fallback={<><Spinner /></>}>{(() => { const __AwaitInner = () => { use(customer.promise); return <><Card c={customer.data} /></> }; __AwaitInner.displayName = "Await(customer)"; return <__AwaitInner /> })()}</Suspense></ErrorBoundary> </>)}export default CustomerCard| modifier | what it does |
|---|---|
signal | threads an AbortSignal; cancels the in-flight call when deps change |
cache | memoizes by deps — revisits are an instant cache hit |
swr | cache + serve stale immediately, revalidate in the background |
retry | re-attempts a failed fetch with backoff |
They stack: resource r(deps) signal cache retry => …. Pick swr or cache
(swr is cache-with-revalidation).
What disappeared
Section titled “What disappeared”- A module cache
Map+ TTL bookkeeping →cache/swr - The retry loop with backoff →
retry - The “show stale, refetch in background” dance →
swr - The
AbortController+ abort guards →signal - All of it kept in sync with the loading/error UI by hand → the
await/pending/errorblock
”Isn’t this just React Query / SWR?”
Section titled “”Isn’t this just React Query / SWR?””Functionally, yes — and that’s the honest comparison. TanStack Query and SWR give you caching, revalidation, and retries declaratively too, and they’re mature with far more knobs. If you already run one, the delta is smaller.
What Reactra changes is the shape: the policy is a few keywords on the
resource itself — no queryKey to invent and keep unique, no separate
useQuery hook wired alongside the component, no client/provider to mount. The
deps in resource customer(id) are the cache key. It reads as part of the
component, because it compiles into it.
It’s not a new runtime
Section titled “It’s not a new runtime”resource customer(id) signal swr retry compiles to one call:
const customer = useResource(id, (id, { signal }) => fetchCustomer(id, signal), { resourceName: "customer", cache: { staleWhileRevalidate: true, ttlMs: 60_000 }, retry: 3,})useResource is a hook in @reactra/resource you can read — the cache, the
revalidation, the backoff live there, once, instead of in every component.
The fetch policy is vocabulary, not boilerplate.