@quanticjs/react-core
v7.3.1
Published
Type-safe API client for QuanticJS backends — ProblemDetails errors, request interceptors, auth refresh, React integration
Readme
@quanticjs/react-core
Type-safe API client for QuanticJS backends with ProblemDetails error handling, request interceptors, and React context integration.
Install
pnpm add @quanticjs/react-coreAPI Client
createClient(config)
Factory that creates a type-safe HTTP client using fetch.
import { createClient, correlationId, tenantId, bearerAuth } from '@quanticjs/react-core';
const client = createClient({
baseUrl: '/api',
credentials: 'include',
interceptors: [
correlationId(),
tenantId(() => store.getState().tenantId),
],
});Config
| Property | Type | Default | Description |
|---|---|---|---|
| baseUrl | string | required | Base URL prepended to all request paths |
| credentials | RequestCredentials | 'include' | Fetch credentials mode |
| headers | Record<string, string> | {} | Default headers for every request |
| interceptors | RequestInterceptor[] | [] | Functions that transform requests before sending |
| auth | AuthConfig | — | Optional token refresh config (see below) |
| fetch | typeof fetch | globalThis.fetch | Custom fetch implementation |
| observability | ObservabilityHooks | — | Optional request lifecycle hooks (see below) |
| timeoutMs | number | 30_000 | Request timeout; 0 disables (see Resilience) |
| retry | RetryConfig | — | Automatic retry for idempotent requests; disabled when absent (see Resilience) |
| redactServerErrors | boolean \| ServerErrorRedactor | false (true in createDefaultClient) | Strip server-provided detail from 5xx errors (see Error Handling) |
| unwrapEnvelopes | boolean | false | Unwrap backend result envelopes centrally (see Response Envelope Unwrapping) |
Methods
client.get<T>(path, options?) // GET
client.post<T>(path, body?, options?) // POST (JSON or undefined)
client.put<T>(path, body, options?) // PUT
client.patch<T>(path, body, options?) // PATCH
client.delete<T>(path, options?) // DELETE
client.upload<T>(path, formData, options?) // POST (multipart/form-data)
client.cancelAll(reason?) // Abort every in-flight requestRequest Options
interface RequestOptions {
headers?: Record<string, string>; // Per-request headers
signal?: AbortSignal; // Abort controller signal
params?: Record<string, string | number | boolean | undefined | null>; // Query params
timeoutMs?: number; // Per-request timeout; 0 disables
retry?: false; // Opt this request out of automatic retry
}Query params are auto-serialized — null/undefined values are omitted:
client.get('/items', { params: { page: 1, search: 'test', tag: undefined } });
// → GET /api/items?page=1&search=testError Handling
All non-2xx responses are parsed as RFC 9457 ProblemDetails and thrown as ApiError.
ApiError
import { ApiError, isApiError, ErrorType } from '@quanticjs/react-core';
try {
await client.post('/items', data);
} catch (err) {
if (isApiError(err)) {
err.status; // HTTP status code
err.title; // Short error title
err.detail; // Human-readable description
err.correlationId; // Request correlation ID for debugging
err.errorType; // ErrorType enum value (if resolvable)
err.retryAfter; // Seconds to wait (429 responses)
err.instance; // Problem instance URI
// Validation helpers
err.isValidation; // true for 400/422
err.fieldErrors; // Record<string, string[]> — grouped by field
err.hasFieldError('email'); // boolean
err.getFieldErrors('email'); // string[]
// Status shorthands
err.isNotFound; // 404
err.isUnauthorized; // 401
err.isForbidden; // 403
err.isConflict; // 409
err.isRateLimited; // 429
err.isServerError; // 5xx
}
}ErrorType Enum
Mirrors the backend ErrorType exactly:
NOT_FOUND | FORBIDDEN | CONFLICT | VALIDATION_ERROR | INTERNAL_ERROR
UNAUTHORIZED | UNPROCESSABLE_ENTITY | RATE_LIMITEDResolved from the type URI in ProblemDetails (e.g., https://quantic.dev/errors/NOT_FOUND).
Network Errors
Fetch failures (offline, DNS, timeout) are wrapped as ApiError with status: 0.
Server Error Redaction
A backend that accidentally leaks stack traces, SQL fragments, or internal paths in a 5xx detail must not expose them to the browser. With redactServerErrors enabled, 5xx responses are sanitized before the ApiError is thrown (and before onError fires — telemetry receives the redacted error too):
detailis removed;error.messagefalls back totitlestatus,title,instance, andcorrelationIdare preserved for support lookups- 4xx errors (including 400/422 validation with
errors[]) are never redacted — field errors keep flowing touseForm - Network errors (
status: 0) are not redacted — theirdetailis client-generated
createDefaultClient enables redaction by default; createClient leaves it off for backwards compatibility. Disable it during local development to see raw details:
const client = createDefaultClient({
redactServerErrors: false, // dev only — never disable in production
});Pass a function for org-specific policies. It receives the raw 5xx ProblemDetails and returns the sanitized one; if it throws, the built-in redaction is applied instead:
const client = createDefaultClient({
redactServerErrors: (problem) => ({
...problem,
detail: `Contact support with reference ${problem.correlationId}.`,
}),
});The built-in rule is also exported for reuse (e.g. when handling raw fetch responses yourself):
import { redactProblemDetails } from '@quanticjs/react-core';
const safe = redactProblemDetails(problem); // no-op for non-5xxResponse Envelope Unwrapping
Backends following the platform result convention wrap response bodies in an envelope:
interface ResultEnvelope<T> {
isSuccess?: boolean; // omitted in the abbreviated success form
value: T; // the actual payload
error?: { // present when isSuccess is false
status?: number;
title?: string;
detail?: string;
type?: string;
errors?: ValidationFieldError[];
};
correlationId?: string; // tolerated metadata
timestamp?: string; // tolerated metadata
}With unwrapEnvelopes: true, the client unwraps envelopes centrally after JSON parsing, on every method including upload:
const client = createDefaultClient({ unwrapEnvelopes: true });
// Backend sends { isSuccess: true, value: { id: 1 } } — you receive { id: 1 }
const user = await client.get<User>('/users/1');The envelope contract:
{ isSuccess: true, value: X }and{ value: X }→X(avalueofnull/undefinedis a legitimate empty result){ isSuccess: false, error }inside an ok response → throwsApiErrorbuilt from the envelope'serror(status precedence:error.status, then the transport status, then500). It flows through the standard error pipeline:onErrorfires, 5xx redaction applies,useFormmapserrors[]to fields, query retry predicates see it.{ isSuccess: false }with noerror→ throws a generic 500ApiError- Bare bodies pass through unchanged — arrays, primitives, and objects without a
valuekey are returned as-is, so the flag is safe against backends that emit unwrapped responses. An object holding avaluekey plus other data keys is treated as a bare body too; onlycorrelationIdandtimestampare tolerated alongside the envelope keys. - Error-status responses (e.g. a 400 carrying an envelope) take the normal
ApiErrorpath; unwrapping only applies to ok responses. 204/empty responses are unaffected. - Envelopes are unwrapped one level only — a double-wrapped envelope is a backend bug.
The pure utility is exported for manual use (e.g. in a shared fetcher when the client flag can't be set):
import { unwrapEnvelope, isResultEnvelope } from '@quanticjs/react-core';
const data = unwrapEnvelope<User[]>(await client.get('/users'));Interceptors
Interceptors transform the request context before it's sent. Three built-in factories:
correlationId()
Adds X-Correlation-ID: <uuid> to every request.
import { correlationId } from '@quanticjs/react-core';
createClient({ interceptors: [correlationId()] });tenantId(getter)
Adds X-Tenant-ID header. Skipped if the getter returns null/undefined.
import { tenantId } from '@quanticjs/react-core';
createClient({ interceptors: [tenantId(() => currentTenantId)] });bearerAuth(getter)
Adds Authorization: Bearer <token> header. Skipped if the getter returns null/undefined.
import { bearerAuth } from '@quanticjs/react-core';
createClient({ interceptors: [bearerAuth(() => getAccessToken())] });Custom Interceptors
const logger: RequestInterceptor = (ctx) => {
console.debug(`${ctx.init.method} ${ctx.url}`);
return ctx;
};Auth Refresh
For non-BFF deployments (e.g., mobile apps with access tokens), the client can transparently refresh on 401:
createClient({
baseUrl: '/api',
auth: {
refresh: async () => {
await fetch('/auth/refresh', { method: 'POST', credentials: 'include' });
},
onRefreshFailure: () => {
window.location.href = '/login';
},
},
});Concurrent 401s share a single refresh call (coalesced promise). After refresh, the original request is retried once.
BFF projects: Do NOT use
auth— the backend middleware handles token refresh transparently.
Observability
Optional, no-op-by-default lifecycle hooks for APM integration, audit logging, and request tracing. Available on both createClient and createDefaultClient:
interface RequestEvent {
method: string;
url: string;
correlationId?: string; // from the X-Correlation-ID request header
status?: number; // absent for network errors / aborts
durationMs?: number; // absent on onRequestStart
aborted?: boolean;
isRetry?: boolean; // true for retry attempts (post-refresh or transient retry)
}
interface ObservabilityHooks {
onRequestStart?: (event: RequestEvent) => void;
onRequestEnd?: (event: RequestEvent) => void;
onError?: (error: ApiError, event: RequestEvent) => void;
onAuthRefresh?: (result: { success: boolean; durationMs: number }) => void;
}Lifecycle per request: onRequestStart → fetch → onRequestEnd → (onError if the request ultimately throws). A 401 → refresh → retry fires a full event cycle for the retry with isRetry: true, plus exactly one onAuthRefresh (concurrent 401s sharing a refresh report it once). Aborted requests fire onRequestEnd with aborted: true and no onError; network errors (status 0) fire onError only.
Exceptions thrown by hooks are swallowed — a broken telemetry callback never breaks a request.
APM integration example
import { createDefaultClient, type RequestEvent } from '@quanticjs/react-core';
const client = createDefaultClient({
observability: {
onRequestEnd: (event) => {
apm.recordTiming('api.request', event.durationMs!, {
method: event.method,
url: event.url,
status: String(event.status),
});
},
onError: (error, event) => {
// Correlation ID links this client-side error to backend logs.
logger.error('API request failed', {
correlationId: event.correlationId,
status: error.status,
title: error.title,
durationMs: event.durationMs,
});
},
onAuthRefresh: ({ success, durationMs }) => {
apm.recordEvent('auth.refresh', { success, durationMs });
},
},
});Resilience
Timeout, retry, and cancellation are built into the client. Each retry attempt is a full physical request: interceptors re-run and observability events fire per attempt.
Timeout
Every request is bounded by an AbortController-based timeout — default 30s, configurable per client and per request, 0 disables. The timeout composes with a caller-supplied signal: whichever aborts first wins. A timed-out request throws an ApiError with status: 0, type slug TIMEOUT, and title: 'Request Timeout'; a caller abort still re-throws the original DOMException (AbortError).
const client = createClient({ baseUrl: '/api', timeoutMs: 10_000 });
await client.get('/report', { timeoutMs: 60_000 }); // slow endpoint, longer budget
await client.get('/stream', { timeoutMs: 0 }); // no timeoutcreateDefaultClient applies the same timeoutMs to its internal auth refresh fetch.
Retry
Opt-in on createClient; on by default in createDefaultClient (maxRetries: 2). Only GET requests retry unless retryMethods says otherwise, and FormData bodies are never retried. Retries trigger on network errors and on retryStatusCodes (default 429, 502, 503, 504), with exponential backoff and full jitter. When the server sends retryAfter (ProblemDetails, seconds) it overrides the computed delay; if it exceeds maxDelayMs the error is thrown immediately with retryAfter intact. The 401 → refresh → retry flow is separate and never counts against maxRetries.
interface RetryConfig {
maxRetries?: number; // default 2
retryStatusCodes?: number[]; // default [429, 502, 503, 504]
retryMethods?: string[]; // default ['GET']
baseDelayMs?: number; // default 300
maxDelayMs?: number; // default 10_000
}
const client = createClient({
baseUrl: '/api',
retry: { maxRetries: 3, retryMethods: ['GET', 'HEAD'] },
});
await client.get('/items', { retry: false }); // opt out per request
const app = createDefaultClient({ retry: false }); // opt out entirelyCancellation
The client tracks all in-flight requests; client.cancelAll(reason?) aborts them with an AbortError. useLogout calls cancelAll('logout') before the logout POST when rendered inside a <QuanticProvider>, so ghost requests never race a dead session. The shared auth refresh is not aborted by cancelAll — only request fetches are.
client.cancelAll('navigation'); // e.g. on route teardownMigration note:
ApiClientgainedcancelAll— hand-rolled test mocks implementing the interface must add it.
React Integration
QuanticProvider
Makes the client available to all @quanticjs/react-query hooks via React context.
import { QuanticProvider } from '@quanticjs/react-core';
<QuanticProvider client={client}>
<App />
</QuanticProvider>useClient()
Access the client directly (rare — prefer useApiQuery/useApiMutation).
import { useClient } from '@quanticjs/react-core';
const client = useClient();Exports
// Client
createClient, type ApiClient, type ClientConfig, type AuthConfig
type RequestContext, type RequestInterceptor, type RequestOptions
type RetryConfig, type RequestEvent, type ObservabilityHooks
type ServerErrorRedactor
// Errors
ApiError, isApiError, ErrorType, redactProblemDetails
type ProblemDetails, type ValidationFieldError
// Envelopes
unwrapEnvelope, isResultEnvelope, type ResultEnvelope
// Interceptors
correlationId, tenantId, bearerAuth
// React
QuanticProvider, useClient, type QuanticProviderProps