Error handling
ApiError, isApiError, custom parseError shapes, and global error hooks.
Every non-2xx response from a use-q fetcher (or hook) throws an ApiError. That's it — one shape, every time, fully typed.
How you handle that error is still TkDodo's error-handling advice: globally (onError on the fetcher), per call (useM / useQ callbacks), or with an error boundary (ApiErrorBoundary + suspense). Pick the layer that has the context.
ApiError
ApiError is a regular Error subclass with extra fields:
class ApiError<TData = unknown> extends Error {
readonly name: "ApiError";
readonly status: number;
readonly statusText: string;
readonly data: TData;
readonly url: string;
readonly method: string;
}The default message is "METHOD url failed with status statusText" (e.g. "GET https://api.example.com/posts/missing failed with 404 Not Found").
The generic TData is whatever your parseError returns. Without parseError, data is the parsed response body — JSON when the response's content-type is application/json, the raw text otherwise, or undefined for an empty body.
isApiError type guard
Use isApiError to narrow errors safely:
import { isApiError } from "@use-q/api-client";
try {
await fetcher.fetch("/facilities/{facilityId}/posts/{postId}", {
params: { facilityId: "f1", postId: "missing" },
});
} catch (err) {
if (isApiError(err)) {
console.error(err.status, err.url, err.data);
} else {
throw err;
}
}In React, the same guard works inside useM's onError (it's also re-exported from @use-q/api-client-react and available as api.isApiError on the client object):
import { isApiError } from "@use-q/api-client-react";
const createPost = useM("createPost", {
onError: (err) => {
if (isApiError(err) && err.status === 422) {
toast.error("Validation failed");
}
},
});
createPost.mutate({ params: { facilityId }, body: { title, body } });Customizing the parsed shape
Most APIs return structured error bodies. Normalize them with parseError and narrow with the type guard's generic, isApiError<T>:
interface ApiProblem {
type: string;
title: string;
detail: string;
errors?: Record<string, string[]>;
}
const fetcher = createFetcher({
baseUrl: "https://api.example.com",
parseError: ({ response, data }) => {
const json = data as Partial<ApiProblem> | null;
return {
type: json?.type ?? "about:blank",
title: json?.title ?? response.statusText,
detail: json?.detail ?? "",
errors: json?.errors,
} satisfies ApiProblem;
},
});parseError receives { response, data } — the raw Response plus the already-parsed body (JSON when possible, text otherwise). Its return value becomes ApiError.data. Downstream catch blocks narrow with isApiError<ApiProblem>:
catch (err) {
if (isApiError<ApiProblem>(err)) {
if (err.data.errors) {
for (const [field, messages] of Object.entries(err.data.errors)) {
form.setError(field, { message: messages[0] });
}
}
}
}The same parseError option is accepted by createApiClient, so hook errors carry the same shape:
const api = createApiClient(schema, {
baseUrl: "https://api.example.com",
parseError: ({ response, data }) => /* … */,
});Global error handling
onError runs for every failure across every route. It's the right place to put cross-cutting concerns.
Logout on 401
import { isApiError } from "@use-q/api-client";
const fetcher = createFetcher({
baseUrl: "https://api.example.com",
onError: (err) => {
if (isApiError(err) && err.status === 401) {
tokenStore.clear();
window.location.assign("/login");
}
},
});Telemetry
import * as Sentry from "@sentry/browser";
import { isApiError } from "@use-q/api-client";
createFetcher({
baseUrl: "https://api.example.com",
onError: (err) => {
Sentry.captureException(err, {
tags: {
method: isApiError(err) ? err.method : "unknown",
status: isApiError(err) ? String(err.status) : "network",
},
extra: { url: isApiError(err) ? err.url : undefined },
});
},
});onError receives a single argument — the error itself. For HTTP failures that's the normalized ApiError (so method, url, status are right there on it); for network failures it's the raw thrown error.
Per-call onError (React)
useM also accepts TanStack Query's onError callback. Both run — the fetcher-level onError first, then the mutation's:
useM("createPost", {
onError: (err) => toast.error(`Couldn't save: ${err.message}`),
});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…
} else {
throw err;
}
}React: <ApiErrorBoundary>
When you use useSuspenseQ, errors stop bubbling through render — they bubble to the nearest error boundary. ApiErrorBoundary catches ApiErrors specifically (anything else is re-thrown to the next boundary) and hands your fallback a pre-narrowed error plus a reset:
import { ApiErrorBoundary } from "@use-q/api-client-react";
<ApiErrorBoundary
fallback={({ error, reset }) => (
<div role="alert">
<p>{error.status === 404 ? "Not found" : "Something broke"}</p>
<button onClick={reset}>Try again</button>
</div>
)}
>
<Suspense fallback={<p>Loading…</p>}>
<PostList facilityId="f1" />
</Suspense>
</ApiErrorBoundary>;The fallback receives { error, reset } — error is already an ApiError<unknown>, so no guard is needed inside. See <ApiErrorBoundary> for reset() semantics.
parseError should return a value, not throw. Its return value becomes ApiError.data — unless you return an ApiError instance, in which case that instance is thrown as-is (useful when you want to control the message or subclass ApiError yourself).