Error handling
Every non-2xx response from a use-q fetcher (or hook) throws an ApiError. That’s it — one shape, every time, fully typed.
ApiError
Section titled “ApiError”ApiError is a regular Error subclass with extra fields:
class ApiError<TParsed = unknown> extends Error { readonly name: "ApiError"; readonly status: number; readonly statusText: string; readonly url: string; readonly method: string; readonly parsed: TParsed; readonly response: Response;}The generic TParsed is whatever your parseError returns. Without parseError, it defaults to { message: string; [k: string]: unknown }.
isApiError type guard
Section titled “isApiError type guard”Use isApiError to narrow errors safely:
import { isApiError } from "@use-q/api-client";
try { await fetcher.fetch("getPost", { pathParams: { facilityId: "f1", postId: "missing" }, });} catch (err) { if (isApiError(err)) { console.error(err.status, err.url, err.parsed); } else { throw err; }}In React, the same guard works inside <ApiErrorBoundary> and useM’s onError:
import { isApiError } from "@use-q/api-client-react";
const createPost = useM("createPost", { pathParams: { facilityId }, onError: (err) => { if (isApiError(err) && err.status === 422) { toast.error("Validation failed"); } },});Customizing the parsed shape
Section titled “Customizing the parsed shape”Most APIs return structured error bodies. Surface them in your types with the optional TParsed generic:
interface ApiProblem { type: string; title: string; detail: string; errors?: Record<string, string[]>;}
const fetcher = createFetcher<typeof schema, ApiProblem>(schema, { baseUrl: "https://api.example.com", parseError: async (res) => { const json = (await res.json().catch(() => null)) as ApiProblem | null; return { type: json?.type ?? "about:blank", title: json?.title ?? res.statusText, detail: json?.detail ?? "", errors: json?.errors, }; },});Now downstream catch blocks see err.parsed as ApiProblem:
catch (err) { if (isApiError<ApiProblem>(err)) { if (err.parsed.errors) { for (const [field, messages] of Object.entries(err.parsed.errors)) { form.setError(field, { message: messages[0] }); } } }}The same TParsed flows through createApiClient:
const api = createApiClient<typeof schema, ApiProblem>(schema, { baseUrl: "https://api.example.com", parseError: async (res) => /* … */,});Global error handling
Section titled “Global error handling”onError runs for every failure across every route. It’s the right place to put cross-cutting concerns.
Logout on 401
Section titled “Logout on 401”import { isApiError } from "@use-q/api-client";
const fetcher = createFetcher(schema, { baseUrl: "https://api.example.com", onError: (err) => { if (isApiError(err) && err.status === 401) { tokenStore.clear(); window.location.assign("/login"); } },});Telemetry
Section titled “Telemetry”import * as Sentry from "@sentry/browser";
createFetcher(schema, { baseUrl: "https://api.example.com", onError: (err, ctx) => { Sentry.captureException(err, { tags: { route: ctx.routeName, method: ctx.method, status: String(ctx.status ?? "network"), }, extra: { url: ctx.url }, }); },});Per-call onError (React)
Section titled “Per-call onError (React)”Hooks accept an additional onError. Both run — global first, then local:
useM("createPost", { pathParams: { facilityId }, onError: (err) => toast.error(`Couldn't save: ${err.message}`),});Network errors
Section titled “Network errors”A thrown TypeError: Failed to fetch (no response) propagates without being wrapped — your onError still runs, but isApiError returns false. Handle both cases:
catch (err) { if (isApiError(err)) { // HTTP-level error } else if (err instanceof TypeError) { // Network down, CORS, DNS, abort… } else { throw err; }}React: <ApiErrorBoundary>
Section titled “React: <ApiErrorBoundary>”When you use useSuspenseQ, errors stop bubbling through render — they bubble to the nearest error boundary. ApiErrorBoundary wraps that pattern with a typed fallback:
import { ApiErrorBoundary, isApiError } from "@use-q/api-client-react";
<ApiErrorBoundary fallback={(error, reset) => ( <div role="alert"> <p>{isApiError(error) ? error.parsed.title : "Something broke"}</p> <button onClick={reset}>Try again</button> </div> )}> <Suspense fallback={<p>Loading…</p>}> <PostList facilityId="f1" /> </Suspense></ApiErrorBoundary>;See <ApiErrorBoundary> for reset() semantics.