Error Handling
A resilient error strategy for the whole app: network vs application errors, error boundaries for the render path, status-aware API handling, retries with backoff, graceful user feedback, and production monitoring — beginner to advanced in one page.
Two categories, two tools. Frontend errors split cleanly. Rendering errors — an exception thrown during render, in a lifecycle method, or in a child's constructor — are caught by an Error Boundary. Everything else — event handlers, async code, and data fetching — is caught by ordinary try/catch (or a promise .catch). Using the wrong tool is the #1 error-handling mistake: boundaries do NOT catch async or network errors.
Network vs application errors. A network error means the request never got a response — the server is down, the connection dropped, a timeout fired, or CORS blocked it (error.request in Axios, no error.response). An application error means the server responded but with a failure status — 400/401/403/404/422/500 (error.response present with a status). They need different UX: network errors say 'check your connection / retrying', application errors show a specific message tied to the status.
Error boundaries — the render-path safety net. Boundaries must be class components: they implement static getDerivedStateFromError to switch to a fallback UI and componentDidCatch to log the error (e.g. to Sentry). Wrap sections of the app so a crash in one widget shows a fallback instead of unmounting the whole tree (a blank white screen). Place several small boundaries around independent regions rather than one giant boundary at the root.
What boundaries deliberately miss. They do not catch errors in event handlers, setTimeout/promise callbacks, data fetching, server-side rendering, or errors thrown in the boundary itself — none of those happen during rendering. In modern apps you often use the community react-error-boundary package for a hook-friendly API (useErrorBoundary, a FallbackComponent, and an onReset), but under the hood it's still a class.
Status-aware API handling. For the network path, a reusable safeApiCall/handleError inspects the error with axios.isAxiosError and branches on error.response?.status: 404 → 'not found', 422 → surface field validation, 401 → refresh/redirect, 429 → back off, 5xx → 'server error, try again', and no response → 'network error'. Centralizing this keeps components focused on rendering rather than error plumbing.
Retries and backoff for transient errors. Some failures are transient (5xx, 429, dropped connections) and worth retrying; deterministic ones (400, 422) are not — retrying just wastes time and can annoy the user. Retry up to a small max with exponential backoff (base * 2^attempt) plus jitter to avoid a thundering herd, and honor a Retry-After header on 429. Data libraries like React Query do this for you (retry, retryDelay), which is usually where retry logic belongs.
Graceful user feedback. Every failure needs a human-readable outcome. Use toasts for transient/recoverable errors, inline field errors for 422 validation, a fallback region (from the error boundary) for a crashed widget, and a full-page error with a retry button for a fatal load failure. Never show a raw stack trace or a bare 'Error' — and never silently swallow an error, which hides bugs from both users and you.
Loading, empty, error, and success are four states. A robust data component always models all four, not just success. React Query gives you isLoading, isError, error, and data; render a spinner, an error state with retry, an empty state, and the data respectively. 'It works on my machine' bugs are usually an unmodeled error or empty state.
Optimistic updates and rollback. For snappy UX you can apply a mutation to the UI immediately and roll back if the request fails. This is powerful but error-prone: you must keep the previous state and restore it in the error handler (React Query's onMutate/onError/onSettled formalize this). If you can't cleanly roll back, prefer a pending state instead.
Global handlers catch what you missed. Register window.addEventListener('error', ...) and window.addEventListener('unhandledrejection', ...) to catch stray errors and rejected promises that no local handler caught, and report them to monitoring. This is a safety net, not a substitute for local handling — but it surfaces the errors you didn't anticipate.
Monitoring in production. Log caught errors to a service (Sentry, Datadog) from componentDidCatch and your global handlers, with context (user id, route, release). This is how you learn about crashes real users hit — the console is invisible in production. Attach a release/version so you can tell which deploy introduced a regression.
The mental model (memorise this). Two paths, two tools: error boundaries wrap the render path so a crash degrades to a fallback, and try/catch handles the async/network path. Distinguish network (no response) from application (bad status) errors, branch on status for the right message, retry only transient failures with backoff, give the user a real state (loading/empty/error/success) for every fetch, and always log to monitoring so production failures aren't invisible.
An Error Boundary is the React equivalent of a global exception handler — Spring's `@ControllerAdvice`/`@ExceptionHandler` or a Vert.x failure handler — one place to catch unhandled errors on the render path and return a safe fallback instead of a 500-equivalent white screen. The status-aware `safeApiCall` is the client mirror of mapping exceptions to HTTP responses: you branch on the status to produce the right user-facing outcome, just as your backend maps `EntityNotFoundException` → 404 and `ValidationException` → 422. Retry-with-backoff is the same Resilience4j `Retry` + `CircuitBreaker` pattern you apply to flaky downstream calls, and reporting to Sentry is your client-side equivalent of structured logging + APM. Global `unhandledrejection` handlers are the browser's version of an uncaught-exception handler / dead-letter queue for errors nothing else caught.
- Two categories, two tools: error boundaries catch render-path errors; try/catch (or .catch) catches event-handler, async, and network errors — boundaries do NOT catch those.
- Network error = no response came (server down, timeout, CORS; error.request). Application error = server responded with a failure status (error.response). They need different UX.
- Error boundaries are class components using getDerivedStateFromError (fallback UI) + componentDidCatch (logging); prefer several small boundaries over one at the root.
- Branch on axios.isAxiosError(err) then error.response?.status: 404 not found, 422 validation, 401 refresh, 429 backoff, 5xx retry, no response = network.
- Retry only transient failures (5xx, 429, network) with exponential backoff + jitter and Retry-After; never retry 400/422.
- Model four states for every fetch — loading, empty, error, success — not just the happy path.
- Give real feedback: toasts for transient errors, inline errors for validation, fallback regions for crashes, full-page error+retry for fatal loads. Never swallow an error silently.
- Optimistic updates need explicit rollback (save previous state, restore on error) or a pending state instead.
- Register window 'error' and 'unhandledrejection' handlers as a global safety net that reports to monitoring.
- Always log caught errors to a monitoring service (Sentry) with context and release — the console is invisible in production.
Worked Code
import { Component, ErrorInfo, ReactNode } from 'react';
interface Props { children: ReactNode; fallback?: ReactNode; }
interface State { hasError: boolean; error?: Error; }
class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
// 1) Switch to a fallback UI when a child throws during render
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
// 2) Side effect: log to a monitoring service
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('ErrorBoundary caught:', error, info.componentStack);
// Sentry.captureException(error, { extra: { componentStack: info.componentStack } });
}
render() {
if (this.state.hasError) {
return this.props.fallback ?? <p>Something went wrong.</p>;
}
return this.props.children;
}
}
// Wrap independent regions so one crash doesn't blank the whole app
// <ErrorBoundary fallback={<ErrorPanel />}><ExpenseWidget /></ErrorBoundary>import axios from 'axios';
function describeError(error: unknown): string {
if (axios.isAxiosError(error)) {
if (error.response) {
// Application error: the server answered with a failure status
switch (error.response.status) {
case 401: return 'Please log in again.';
case 403: return 'You are not allowed to do that.';
case 404: return 'We could not find that.';
case 422: return 'Please fix the highlighted fields.';
case 429: return 'Too many requests — slow down.';
default: return error.response.status >= 500
? 'Server error — please try again.'
: 'Request failed.';
}
}
if (error.request) {
// Network error: request sent, but NO response (down/timeout/CORS)
return 'Network problem — check your connection.';
}
}
return 'Something unexpected happened.'; // setup/programming error
}import axios from 'axios';
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
// Retry only TRANSIENT failures with exponential backoff + jitter
async function withRetry<T>(fn: () => Promise<T>, max = 3): Promise<T> {
for (let attempt = 0; ; attempt++) {
try { return await fn(); }
catch (err) {
const status = axios.isAxiosError(err) ? err.response?.status : undefined;
const transient = status === undefined || status >= 500 || status === 429;
if (!transient || attempt >= max) throw err; // never retry 400/422
await sleep(300 * 2 ** attempt + Math.random() * 100);
}
}
}
// Run a call safely, show feedback, and return null on failure
async function safeApiCall<T>(fn: () => Promise<T>): Promise<T | null> {
try {
return await withRetry(fn);
} catch (error) {
toast.error(describeError(error)); // human-readable, status-aware
// Sentry.captureException(error);
return null; // caller renders an error/empty state
}
}import { useQuery } from '@tanstack/react-query';
function Expenses() {
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: ['expenses'],
queryFn: getExpenses,
retry: (count, err) =>
axios.isAxiosError(err) && (err.response?.status ?? 500) >= 500 && count < 3,
});
if (isLoading) return <Spinner />; // loading
if (isError) return <ErrorState msg={describeError(error)} onRetry={refetch} />; // error
if (!data?.length) return <EmptyState />; // empty
return <ExpenseList items={data} />; // success
}
// Global safety net for anything no local handler caught
window.addEventListener('unhandledrejection', e => {
console.error('Unhandled rejection:', e.reason);
// Sentry.captureException(e.reason);
});
window.addEventListener('error', e => {
console.error('Uncaught error:', e.error);
});▶Try It Live
Edit the code and press Run — it executes safely in a sandboxed iframe. Use the Console tab for log output.
Interview-Ready Q&A
It catches errors thrown during rendering, in lifecycle methods, and in constructors of the components below it, rendering a fallback UI instead of a blank screen. It does NOT catch errors in event handlers, asynchronous code (setTimeout, promises), data fetching, server-side rendering, or errors thrown in the boundary itself — those need try/catch or a .catch.
- 1Two paths, two tools: error boundaries (render path) vs try/catch (async/network). Boundaries do NOT catch async/network errors.
- 2Network error = no response (error.request); application error = failure status (error.response). Different UX for each.
- 3Boundaries are class components: getDerivedStateFromError (fallback) + componentDidCatch (log). Use several small ones.
- 4Branch on axios.isAxiosError + status: 404, 422 validation, 401 refresh, 429 backoff, 5xx retry, none = network.
- 5Retry only transient failures (5xx/429/network) with exponential backoff + jitter + Retry-After; never 400/422.
- 6Model four states per fetch: loading, empty, error, success. Never swallow an error silently.
- 7Feedback map: toast (transient), inline (validation), fallback region (crash), full-page+retry (fatal load).
- 8Global window 'error'/'unhandledrejection' handlers + Sentry logging with release context surface production crashes.