npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-core

API 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 request

Request 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=test

Error 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_LIMITED

Resolved 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):

  • detail is removed; error.message falls back to title
  • status, title, instance, and correlationId are preserved for support lookups
  • 4xx errors (including 400/422 validation with errors[]) are never redacted — field errors keep flowing to useForm
  • Network errors (status: 0) are not redacted — their detail is 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-5xx

Response 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 (a value of null/undefined is a legitimate empty result)
  • { isSuccess: false, error } inside an ok response → throws ApiError built from the envelope's error (status precedence: error.status, then the transport status, then 500). It flows through the standard error pipeline: onError fires, 5xx redaction applies, useForm maps errors[] to fields, query retry predicates see it.
  • { isSuccess: false } with no error → throws a generic 500 ApiError
  • Bare bodies pass through unchanged — arrays, primitives, and objects without a value key are returned as-is, so the flag is safe against backends that emit unwrapped responses. An object holding a value key plus other data keys is treated as a bare body too; only correlationId and timestamp are tolerated alongside the envelope keys.
  • Error-status responses (e.g. a 400 carrying an envelope) take the normal ApiError path; 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 timeout

createDefaultClient 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 entirely

Cancellation

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 teardown

Migration note: ApiClient gained cancelAll — 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