Fetch, cache, and mutate data from OpenAPI-typed services with full type safety.
Built on openapi-fetch. Every path, parameter, and response is inferred from your OpenAPI schema — no manual typing.
npm install @beyond.dev/openapi-react openapi-fetch openapi-typescript-helpersGenerate types from your OpenAPI spec (see openapi-typescript), then create a client:
import { createClient } from "@beyond.dev/openapi-react";
import type { paths } from "./api.d.ts"; // generated by openapi-typescript
export const api = createClient<paths>({
baseUrl: "https://api.example.com",
});Fetch data in a component with useLoader (requires a <Suspense> boundary):
import { Suspense } from "react";
import { api } from "./api";
function UserList() {
const { data } = api.useLoader({
path: "GET /users",
input: { query: { limit: 20 } },
});
return <ul>{data.items.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}
export default function App() {
return (
<Suspense fallback={<p>Loading...</p>}>
<UserList />
</Suspense>
);
}Cached GET with Suspense support. Suspends while loading, throws on error.
const { data, status, fetchStatus, invalidate, refetch } = api.useLoader({
path: "GET /users/{id}",
input: { path: { id: "u_123" } },
staleTime: 5000, // ms before re-fetching (default: 1000)
disabled: false, // skip fetch entirely
refetchOnMount: true, // re-fetch when component mounts (default: true)
refetchOnFocus: true, // re-fetch on window focus (default: true)
refetchOnReconnect: true, // re-fetch on network reconnect (default: true)
refetchInterval: 30000, // poll every N ms, or (data) => ms | false
});| Field | Type | Description |
|---|---|---|
data |
T |
Response data |
status |
"success" | "error" | "disabled" |
Logical render state (see note below) |
fetchStatus |
"fetching" | "refetching" | "success" | "error" | "uncached" |
Raw network state (see note below) |
invalidate() |
() => void |
Mark stale, triggers background refetch |
refetch() |
() => Promise<CachedResponse> |
Force immediate refetch |
status vs fetchStatus
status is the logical render state — it collapses "refetching" into "success" (stale data stays visible during a background refresh) and is what you branch on in JSX. fetchStatus is the raw network state and exposes "refetching" separately, useful for showing a subtle background-refresh indicator without hiding existing data. When disabled: true, fetchStatus is "uncached".
// Branch on status for rendering decisions:
if (result.status === "error") return <ErrorMessage />;
// Use fetchStatus to layer a background-refresh hint over existing data:
{
result.fetchStatus === "refetching" && <Spinner size="sm" />;
}
{
result.data && <UserList users={result.data} />;
}Same as useLoader but without Suspense. Returns a discriminated union instead.
const result = api.useInlineLoader({
path: "GET /users/{id}",
input: { path: { id: "u_123" } },
});
if (result.status === "fetching") return <Spinner />;
if (result.status === "error") return <Error data={result.error} />;
return <User data={result.data} />;The "success" variant includes lastError — the previous error if a refetch succeeds after a failure.
Uncached mutations. Returns a send function and the current status.
const { send, status } = api.useAction({
path: "POST /users",
onSuccess(data, response) {/* redirect, update cache, etc. */},
onError(error, response) {/* show toast, etc. */},
onSettled(data, error, response) {/* runs on both success and error */},
});
// Call send with typed input
const user = await send({
body: { name: "Alice", email: "alice@example.com" },
});| Field | Type | Description |
|---|---|---|
send |
(input, requestInit?) => Promise<T> |
Execute the request |
status |
"idle" | "fetching" | "success" | "error" |
Current mutation state |
send throws ErrorResponse on failure. The onError callback receives the typed error data.
onSettled(data, error, response) runs after every send regardless of outcome — useful for cleanup that must always happen (closing a modal, resetting a form). data is defined on success; error is defined on failure.
Fetch programmatically. Respects staleTime — returns cached data if fresh.
const cached = await api.load({
path: "GET /users",
staleTime: 0, // force refetch
});Pre-populate the cache without a network request.
Note:
hydrateis a no-op when the cache already holds a successful entry for that key. To overwrite existing data, callpurgefirst thenhydrate.
api.hydrate({
path: "GET /users/{id}",
data: { id: "u_123", name: "Alice" },
input: { path: { id: "u_123" } },
});Mark a cache entry stale. Components subscribed to that key will refetch in the background.
// Exact match
api.invalidate({ path: "GET /users/{id}", input: { path: { id: "u_123" } } });
// Pattern match — invalidate all /users entries
api.invalidate({
match({ path }) {
return path.startsWith("GET /users");
},
});Invalidate and immediately re-fetch.
await api.refetch({ path: "GET /users" });
// Pattern match
await api.refetch({
match({ path }, mountCount) {
return path === "GET /users" && mountCount > 0;
},
});The match callback receives the parsed cache key and the current mount count (refCount).
Remove entries from the cache entirely.
api.purge({ path: "GET /users/{id}", input: { path: { id: "u_123" } } });Build a typed URL string from a path and parameters.
const href = api.url({
path: "GET /users/{id}",
input: { path: { id: "u_123" }, query: { expand: "roles" } },
});
// "https://api.example.com/users/u_123?expand=roles"const api = createClient<paths>({
baseUrl: "https://api.example.com",
// How long cached data is considered fresh (ms). Default: 1000.
staleTime: 5000,
// How long to keep unused cache entries after all subscribers unmount (ms). Default: 300_000.
cacheTime: 60_000,
// Max retries on 5xx errors. Default: 3.
retries: 2,
// Custom retry predicate. Return false to abort, true/void to retry.
shouldRetry(error, retryCount) {
return error.response?.status !== 429;
},
// Transform all responses before caching.
transform: camelize,
// Default request options (credentials, mode, headers).
requestInit: () => ({
credentials: "include",
headers: { "X-App-Version": "1.0" },
}),
// Custom query string serializer.
querySerializer: createQuerySerializer({ array: { style: "form" } }),
// Extend cache keys — useful for user-scoped caches.
extendCacheKey: ({ path, input }) => ({ path, input, userId: getUser().id }),
// Global callbacks.
onEachSuccess(data) {},
onEachError(error) {},
debug: true, // Log requests and responses to the console.
});Recursively converts snake_case keys to camelCase.
import { camelize } from "@beyond.dev/openapi-react";
camelize({ user_id: "u_1", created_at: "2024-01-01" });
// => { userId: "u_1", createdAt: "2024-01-01" }Use as a global transform to normalize API responses across all hooks:
const api = createClient<paths>({ baseUrl, transform: camelize });Shallow converts camelCase keys to snake_case (top-level keys only).
import { snakenize } from "@beyond.dev/openapi-react";
snakenize({ userId: "u_1", firstName: "Alice" });
// => { user_id: "u_1", first_name: "Alice" }Use it when building request bodies that expect snake_case from camelCase inputs.
Failed requests throw ErrorResponse<T>, which carries the typed error payload.
import { ErrorResponse } from "@beyond.dev/openapi-react";
try {
await api.load({ path: "GET /users/{id}", input: { path: { id: "x" } } });
} catch (err) {
if (err instanceof ErrorResponse) {
console.log(err.data); // typed error body
console.log(err.response); // raw Response
}
}With useLoader (Suspense mode), errors propagate to the nearest error boundary. With useInlineLoader, they surface as status: "error" with result.error typed to your schema's error shape.