Error Handling
Most bugs I hunt for hours turn out to be errors that were silently caught and discarded. So my rule is the opposite: throw at the boundary, handle at the UI, never swallow.
Three layers, three jobs
| Layer | Job | What it looks like |
|---|---|---|
| Boundary (API client, parsers) | Convert raw errors into typed app errors | Wrap Axios errors into ApiError |
| Domain (services, hooks) | Let typed errors bubble up. Don’t catch unless you can recover | No try/catch unless you have a real fallback |
| UI (components) | Show the user something. Always. | Error boundaries + per-query error states |
If a middle layer catches an error to log it and then re-throws, that’s fine. If it catches an error to “be safe” and returns null, that’s a bug I’ll spend a week finding.
Typed errors at the boundary
export class ApiError extends Error {
constructor(
public status: number,
public code: string,
message: string,
public details?: unknown,
) {
super(message);
this.name = "ApiError";
}
}
export class ValidationError extends Error {
constructor(public issues: ZodIssue[]) {
super("Validation failed");
this.name = "ValidationError";
}
}
Every catch in the app uses instanceof to discriminate:
try {
await ordersService.create(payload);
} catch (e) {
if (e instanceof ValidationError) showFieldErrors(e.issues);
else if (e instanceof ApiError && e.status === 409) toast("Already exists");
else throw e; // let it bubble to the error boundary
}
Don’t try/catch for the sake of it
// Bad — swallows the error, returns nonsense
async function getUser(id: string) {
try {
return await usersService.get(id);
} catch {
return null;
}
}
// Good — let the caller decide
async function getUser(id: string) {
return await usersService.get(id);
}
The “bad” version forces every caller to check for null and still doesn’t know whether null means “not found” or “server exploded.” That’s strictly worse than a thrown error with a status code.
React Query owns retry + error state
Components don’t try/catch around their useQuery calls. They consume the status / error from the query result:
const { data, status, error } = useOrders(filters);
if (status === "pending") return <Skeleton />;
if (status === "error") return <ErrorBanner error={error} />;
return <OrdersList orders={data} />;
Retries are configured at the QueryClient (don’t retry 4xx, retry 5xx twice). Components just render whatever state they’re handed.
Error boundaries are required, not optional
Every route gets an error boundary above its content. Without one, an unexpected throw crashes the entire app tree. With one, the user sees a “something went wrong, here’s a refresh button” card and the rest of the app still works.
// app/layout.tsx (Next.js) or _layout.tsx (Expo Router)
<ErrorBoundary fallback={<AppErrorScreen />}>
<RouteContent />
</ErrorBoundary>
I also log the error from componentDidCatch / onError into Sentry so I find out about prod crashes without a user emailing me.
Never console.error and move on
Production code never logs an error as its only response to it. Either:
- Handle it — show the user, retry, fall back to cached data — and log it for diagnostics.
- Let it bubble — the error boundary catches it and reports.
console.error alone means “I noticed something was wrong, then I pretended I didn’t.” That’s the path to silent corruption.
A practical checklist
Before I ship a feature, I ask:
- Does every async call have a defined “what does the user see if this fails?”
- Are 4xx and 5xx differentiated? (401 → re-auth, 403 → permission UI, 404 → empty state, 5xx → retry/banner)
- Is there an error boundary above this screen?
- If the network is offline, does the UI degrade gracefully or hang forever?
- Are errors being reported to Sentry (or equivalent) with enough context to debug?
If any of those are “no,” the feature isn’t done.