Skip to content

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 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 }.

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");
}
},
});

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) => /* … */,
});

onError runs for every failure across every route. It’s the right place to put cross-cutting concerns.

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");
}
},
});
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 },
});
},
});

Hooks accept an additional onError. Both run — global first, then local:

useM("createPost", {
pathParams: { facilityId },
onError: (err) => toast.error(`Couldn't save: ${err.message}`),
});

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;
}
}

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.