Takes an OpenAPI-typed Paths schema and produces React hooks (useLoader, useInlineLoader, useAction) and imperative APIs (load, hydrate, invalidate, refetch, purge) backed by a shared in-memory cache with observable change propagation.
Load / useLoader (cached GET)
useLoader(options)
│
├─► encodeKey(path, input)
│ │
│ ▼
│ SubjectMap lookup
│ │
│ hit + within staleTime? ──► return cached data immediately
│ │
│ miss or stale?
│ │
│ ▼
│ retry(fn, { retries, shouldRetry })
│ │
│ ▼
│ openapi-fetch client[GET](path, init)
│ │
│ success error
│ │ │
│ transform(data) ErrorResponse (cached)
│ │ │
│ └──────────┬────────────┘
│ ▼
│ SubjectMap.set(key, entry)
│ │
│ Subject.setState → notify observers
│
└─► useSyncExternalStore(subscribe, getSnapshot)
│
render with { data, error, status, response }
Action / useAction (uncached mutation)
useAction().send(input, init)
│
▼
fetch_(method, path, init) ← raw, no caching
│
▼
openapi-fetch client[METHOD](path, init)
│
├─ success → transform → onSuccess(data)
└─ error → onError(err) → throw ErrorResponse
Cache eviction (background interval)
every min(cacheTime / 4, 5000) ms:
for each entry in SubjectMap:
if createdAt + cacheTime < now AND refCount === 0:
SubjectMap.delete(key)
Subscription / refetch triggers
mount → (refetchOnMount) → load()
window "focus" event ───────────► invalidate → load()
document "visibilitychange" ────► invalidate → load()
window "online" event ──────────► invalidate → load()
refetchInterval timer ──────────► invalidate → load()
manual invalidate() / refetch() ► invalidate → load()
| Term | What It Controls | NOT |
|---|---|---|
staleTime |
Window (ms) after a successful fetch during which cached data is served without re-fetching | When data is deleted from cache |
cacheTime |
How long a cache entry survives after its last subscriber unmounts | How long data is considered fresh |
refCount |
Active subscriber count per cache key; eviction is gated on refCount === 0 |
A render count |
SubjectMap |
The shared in-memory cache; a Map with microtask-batched change notifications |
A persistent or distributed store |
Subject |
Single observable slot wrapping SubjectMap's change stream; drives useSyncExternalStore |
A reactive stream or Rx observable |
encodeKey |
Deterministic JSON serialization (sorted keys, empty values removed) for cache lookup | A hash; collisions are possible if JSON is identical |
LoadablePaths |
Union of Paths keys that have a get method — the only paths valid for useLoader / load |
All paths in the schema |
transform |
Optional user-supplied (data: T) => unknown applied to every successful response before caching |
Middleware; runs once at load, not on each render |
ErrorResponse<T> |
Subclass of Error carrying .data and .response; thrown by hooks and cached as the error state |
A plain fetch rejection |
Returns a closed-over object sharing one SubjectMap (cache), one openapi-fetch client instance, and one eviction interval. All hooks and imperative helpers from a single createClient call share this cache — hooks from different createClient calls do not.
encodeKey({ path, input }) → JSON string
- Recursively sorts object keys and removes
undefined/null/empty-string values before serializing - Ensures
GET /users?page=1&limit=10andGET /users?limit=10&page=1map to the same cache slot - See
client.ts:encodeKey()andclient.ts:sortObject()
- Compute
key = encodeKey(path, input) - If
SubjectMaphas a non-stale entry (withinstaleTime) → return immediately - If a fetch is already in-flight for this key (same
promiseobject) → await that instead of issuing a second request - Call
retry(fetchFn, retryOptions)with exponential backoff - On success: apply
transform, writeCachedResponsewithstatus: "success"intoSubjectMap - On abort: if stale data exists, resolve with it silently; otherwise rethrow
- On error: write
CachedResponsewithstatus: "error"anderror: ErrorData; notify observers - Every write calls
Subject.setState, which batches downstream notifications viaqueueMicrotask
See client.ts:load().
Both subscribe to the same cache via useSyncExternalStore. The difference is in the snapshot shape:
useLoader: Returns a single object{ data, error, status, ... }regardless of state; callers may wrap in a<Suspense>boundary. Whensuspense: trueand data is loading, the hook throws a promise.useInlineLoader: Returns a discriminated union{ status: "success" | "fetching" | "error", ... }for inline conditional rendering without a<Suspense>boundary.
attempt → fetch
on error:
if shouldRetry(error, attemptCount) → wait (base * 2^attempt) ms → retry
else → throw
Default shouldRetry: retries 5xx errors up to retries times; never retries 4xx. Custom shouldRetry can return boolean | void | Promise<boolean | void>. See client.ts:retry().
Thin wrapper that calls fetch_ (raw openapi-fetch with no caching) and manages a local status subject. Each send() call replaces the previous in-flight status. No cache reads or writes. See client.ts:useAction().
disabled ──► (enabled=true) ──► fetching
│
success / error
│
┌────────►▼◄───────────┐
│ loaded │
│ (success/error) │
│ │ │
│ invalidate/refetch │
│ │ │
└──── refetching ──────┘
| From | Event | To | What Actually Happens |
|---|---|---|---|
disabled |
enabled becomes truthy |
fetching |
load() called; component re-renders with status: "fetching" |
fetching |
fetch succeeds | success |
Data written to cache; component re-renders with data |
fetching |
fetch fails | error |
ErrorResponse written to cache; component re-renders with error |
success |
invalidate() or trigger fires |
refetching |
status set to refetching; stale data remains visible; load() called |
error |
invalidate() or trigger fires |
refetching |
Same as above; previous error cleared after new fetch completes |
refetching |
fetch succeeds | success |
Cache updated; fresh data rendered |
refetching |
fetch fails | error |
Error replaces previous state |
The cache always holds the last good entry. On refetch, status becomes refetching but data stays populated. This avoids a loading flash on every background refresh — common for window-focus refetches. If the refetch fails, the component surfaces the error without discarding the last-known-good data.
If a component is mounted, its data must survive even past cacheTime. The eviction interval only deletes entries with no active subscribers. This prevents mid-render cache misses when cacheTime is shorter than a component's lifetime.
Multiple cache keys can be written in the same synchronous frame (e.g., hydrate pre-populating several routes). Batching via queueMicrotask coalesces redundant observer notifications so React only re-renders once per microtask. See client.ts:SubjectMap.
Request bodies often include nested structures (e.g., WebAuthn credential objects, binary blobs) that must not be transformed. snakenize converts only the top-level keys on outgoing payloads. camelize converts deeply on incoming responses where the full object is application-controlled. See src/utils/camelize.ts.
The SubjectMap is created inside createClient, which is module-level. SSR would share cache state across requests. The library is intentionally client-only; hydrate() exists to pre-populate cache from server-fetched data passed as props.
| Option | Default | Runtime Effect |
|---|---|---|
baseUrl |
"" |
Prepended to every request path |
staleTime |
1000 ms |
Data within this age is served from cache without re-fetching |
cacheTime |
300_000 ms (5 min) |
Unmounted entries are evicted after this duration |
retries |
3 |
Max retry attempts on 5xx; does not apply to 4xx |
shouldRetry |
retry 5xx, skip 4xx | Custom predicate — return false to abort, true to retry |
transform |
identity | Applied once to response data before caching; result is what hooks receive |
debug |
false |
Logs each response (status, URL, headers, body) to console |
requestInit |
undefined |
Merged into every request: cache, credentials, mode, headers |
querySerializer |
openapi-fetch default | Replaces the entire query string serialization |
onEachSuccess |
noop | Called after every successful useAction send; useful for analytics |
onEachError |
noop | Called after every failed useAction send |
extendCacheKey |
identity | Add custom fields (e.g., tenant ID) to every cache key |
| Failure | What Actually Happens | Recovery |
|---|---|---|
| HTTP 4xx response | Wrapped in ErrorResponse; not retried; cached as status: "error"; hook renders error state |
Manual refetch() or user action |
| HTTP 5xx response | Retried up to retries times with exponential backoff; on exhaustion, cached as status: "error" |
Automatic on next refetch trigger |
| Fetch aborted (unmount) | If stale data exists, resolves silently with it; otherwise rethrows AbortError |
No action needed; next mount re-fetches |
transform throws |
Error propagates as an uncaught rejection from load(); cache entry is not written |
Fix the transform; or wrap it with try/catch |
| Network offline | fetch rejects; treated as retriable error; browser online event triggers refetch when reconnected |
Automatic via window "online" listener |
| Concurrent mounts for same key | Second subscriber awaits the in-flight promise from the first; no duplicate requests issued |
Handled transparently by the cache |
| File | What It Does |
|---|---|
src/index.ts |
Public surface: re-exports everything from client.ts and utils/camelize.ts |
src/client.ts |
createClient factory; SubjectMap, Subject, retry, encodeKey, all hooks and imperative helpers |
src/utils/camelize.ts |
camelize (deep snake→camel), snakenize (shallow camel→snake), Camelize<T> type |