Skip to content

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.

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} /> }
}
}
Compiled React 19 — this is the file in your repo
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
modifierwhat it does
signalthreads an AbortSignal; cancels the in-flight call when deps change
cachememoizes by deps — revisits are an instant cache hit
swrcache + serve stale immediately, revalidate in the background
retryre-attempts a failed fetch with backoff

They stack: resource r(deps) signal cache retry => …. Pick swr or cache (swr is cache-with-revalidation).


  • A module cache Map + TTL bookkeepingcache / swr
  • The retry loop with backoffretry
  • The “show stale, refetch in background” danceswr
  • The AbortController + abort guardssignal
  • All of it kept in sync with the loading/error UI by hand → the await/pending/error block

”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.


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.