A Complete Error-Handling Strategy for React Apps
Build a mental map of the different kinds of errors a React app encounters — JavaScript errors, TypeScript errors, promise errors, API errors, and render errors — and learn how to turn each into a friendly, logged, appropriately-recoverable experience, in both development and production.
Error handling in React is not one technique — it's several distinct techniques, each covering a different failure mode, and a robust app needs all of them working together. A try/catch around an async call does nothing for a render-time crash; an error boundary does nothing for a rejected promise inside a setTimeout; and neither does anything to make a raw error message actually readable to a non-technical user. This lesson works through the full taxonomy and how to respond to each.
A taxonomy of errors: JS, TS, promise, API, and render errors
JavaScript errors are runtime failures — calling a method on undefined, accessing a property of null, dividing types that don't support the operation — and they throw an Error object (or subclass) the moment the offending line executes. These can happen anywhere: inside an event handler, inside a render function, inside an effect, inside a plain utility function.
TypeScript errors are a different category entirely: they are caught at compile time, before the app ever runs, when a value's declared or inferred type doesn't match how it's used (passing a string where a number is expected, accessing a property TypeScript doesn't believe exists). TypeScript errors never appear to a user in production — by the time code ships, it has already compiled cleanly — but they matter here because a weak TypeScript setup (liberal use of any, disabled strict mode) lets bugs slip through this earliest, cheapest-to-fix stage and surface later as one of the runtime categories instead.
Promise errors are rejections from asynchronous operations — a fetch that fails, a promise-based API that rejects — and they do not throw synchronously; they must be handled with .catch() or a try/catch around an awaited call. An unhandled promise rejection doesn't crash the render tree, but it does log a console warning and silently skip whatever your success-path code was supposed to do, which is its own kind of bug (a spinner that never goes away, state that never updates).
API errors are a specific, common case of promise/JS errors: the backend responded, but with a status or body indicating failure — validation errors, auth failures, not-found, server errors — and they need application-level handling to convert an HTTP status and body into something meaningful, as covered in the API Layer Architecture module's ApiError model. Render errors are JavaScript errors thrown specifically during React's render phase (or in lifecycle methods) — these are the only category an error boundary can catch, which is precisely why error boundaries coexist with, rather than replace, try/catch and .catch() handling elsewhere.
Building error UI and giving users a way to retry
An error caught anywhere in this taxonomy should, in almost every case, result in some UI change — not a silent console.error and an otherwise-unchanged screen. What that UI looks like depends on scope: a failed fetch inside one widget should show an inline error message and retry button within that widget, not take down the whole page; a render error caught by an error boundary should show a fallback for the section that crashed; and a genuinely unrecoverable top-level failure might warrant redirecting to a dedicated error page.
Retry means giving the user an explicit way to re-attempt whatever failed, rather than forcing a full page reload — a button that re-runs the same fetch, resets an error boundary's state, or re-submits a form. Retry UI matters most for errors that are plausibly transient (a flaky network request, a brief server hiccup) as opposed to errors that will reliably recur (invalid input, a resource that genuinely doesn't exist) — offering a retry button for the latter just wastes the user's time, so the message accompanying it should be honest about which situation applies.
A good error UI also avoids trapping the user — pair a retry option with a way out (a link back to a safe page, a 'contact support' link) so a persistent failure doesn't leave someone stuck on a dead end with no next step.
Logging and user-friendly messages
Every caught error worth showing to a user is also worth logging somewhere durable — a monitoring service (like Sentry), your own backend logging endpoint, or at minimum structured console output — because the error UI a user sees tells you almost nothing about the root cause, while the logged error (stack trace, the props/state at the time, the request that failed) is what actually lets you fix the bug. Logging should happen at the point an error is caught — inside a service-layer catch block, inside componentDidCatch, inside a global window.onerror or unhandledrejection listener as a last-resort net — not left to the browser's own uncaught-error console output, which most users (and most production monitoring setups) never see.
A user-friendly message translates a technical error into language a non-technical person can act on. 'TypeError: Cannot read properties of undefined (reading 'map')' means nothing to a user and actively erodes trust; 'Something went wrong loading your orders. Try again.' tells them what happened and what to do next. The mapping from technical error to friendly message is often centralized — for ApiError specifically, mapping known status codes (401 -> 'Please log in again', 404 -> 'That item couldn't be found', 500 -> 'Something went wrong on our end') to specific friendly copy, with a generic fallback message for anything unrecognized.
Critically, the friendly message shown to the user and the detailed error logged for developers are not the same string serving two purposes — they're two separate pieces of output, produced from the same caught error, aimed at two different audiences.
Development vs production handling
In development, you generally want maximum information: full stack traces, the actual error message, perhaps even a dump of relevant state, shown directly in the UI or console, so you can diagnose a bug as fast as possible without leaving the browser. React itself supports this in development mode with its detailed error overlays and component stack traces in error boundary's componentDidCatch.
In production, the opposite trade-off applies: showing a raw stack trace or a backend's internal error message to an end user is both unhelpful (they can't act on it) and a potential security or information-disclosure risk (internal file paths, query structure, or library versions leaking into the browser). Production error UI should show the friendly, translated message described above, while the full technical detail goes only to logging/monitoring, never to the DOM.
The standard way to branch this behavior is checking an environment flag — process.env.NODE_ENV === 'production' (or your bundler's equivalent, such as import.meta.env.PROD in Vite) — inside the place that renders error UI or decides what to log where, so the exact same error-handling code path produces verbose output locally and minimal, safe output once deployed, without needing two separate implementations to maintain.
A combined pattern: error boundary plus environment-aware logging
logError always runs, in both environments — only where and how much detail is displayed to the user changes. The dev-only stack trace in the fallback UI never renders in production, avoiding leaking internals to end users.
class AppErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ error: Error | null }
> {
state = { error: null as Error | null };
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
logError(error, info.componentStack); // always logs full detail
}
reset = () => this.setState({ error: null });
render() {
if (this.state.error) {
const isDev = process.env.NODE_ENV !== "production";
return (
<div role="alert">
<p>Something went wrong.</p>
{isDev && <pre>{this.state.error.stack}</pre>}
<button onClick={this.reset}>Try again</button>
</div>
);
}
return this.props.children;
}
}
function logError(error: Error, componentStack?: string | null) {
if (process.env.NODE_ENV === "production") {
sendToMonitoring({ message: error.message, stack: error.stack, componentStack });
} else {
console.error(error, componentStack);
}
}Code Examples
The raw ApiError (with its numeric status and possibly technical message) never reaches the DOM directly — friendlyMessage translates it into copy a user can understand and act on, while the underlying error object is still available for logging separately.
case 401: return "Your session has expired. Please log in again.";
This is an event-handler-triggered async call, so an error boundary would never see a rejection from it even if one wrapped this component — the try/catch here is the only thing standing between a failed download and a silently broken button.
Common mistake: Assuming an error boundary somewhere up the tree will catch a promise rejection from an onClick handler — it won't; error boundaries only catch errors thrown during rendering, not inside event handlers or async callbacks.
function friendlyMessage(error: ApiError): string {
switch (error.status) {
case 401:
return "Your session has expired. Please log in again.";
case 404:
return "We couldn't find what you were looking for.";
case 422:
return error.message; // validation messages are usually already user-facing
case 500:
case 502:
case 503:
return "Something went wrong on our end. Please try again in a moment.";
default:
return "Something went wrong. Please try again.";
}
}
function OrdersPage() {
const state = useOrders();
if (state.status === "error") {
return (
<div>
<p>{friendlyMessage(state.error)}</p>
<button onClick={state.retry}>Retry</button>
</div>
);
}
// ...
}function DownloadButton({ fileId }: { fileId: string }) {
const [error, setError] = useState<string | null>(null);
async function handleClick() {
setError(null);
try {
const blob = await downloadFile(fileId); // may reject
saveAs(blob, "export.csv");
} catch (err) {
setError("Download failed. Please try again.");
logError(err instanceof Error ? err : new Error(String(err)));
}
}
return (
<div>
<button onClick={handleClick}>Download</button>
{error && <p role="alert">{error}</p>}
</div>
);
}Which mechanism catches which error
No single mechanism covers every error category — a complete strategy layers all of them together.
Render error (thrown while rendering) -> Error Boundary
Event handler error (onClick, onChange) -> try/catch in the handler
Promise rejection (fetch, async/await) -> .catch() / try-catch around await
API error (non-2xx response) -> normalized into ApiError, then handled like any thrown error
TypeScript error -> caught at compile time, before the app ever runsImportant Rules
Match the handling mechanism to the error category: error boundaries for render errors, try/catch or .catch() for promises and event handlers, compile-time type-checking for TypeScript errors.
Log the full technical detail of every caught error somewhere durable, even when the UI shows a simplified message.
Never show a raw stack trace, technical exception message, or backend internals directly to users in production.
Offer retry only for plausibly transient failures, and always pair it with a way out (a link home, a support contact) rather than a dead end.
Branch verbosity, not logic, on environment — the same error-handling code path should run in dev and prod, differing only in how much detail is shown or where it's sent.
Where this shows up
A checkout flow layers all of these together in practice: a top-level error boundary catches any unexpected render crash and shows a 'something went wrong, your cart is safe' fallback; the payment submission button wraps its async call in try/catch to show 'Payment failed, please try again' without an alert() or an unhandled rejection; API errors from the payment service are mapped from status codes to specific messages ('Your card was declined' vs 'We're experiencing issues, please try again shortly'); and every caught error, in every layer, is sent to a monitoring service with full stack traces — while the customer only ever sees calm, specific, actionable copy.
try { await submitForm(values); } catch (err) { alert(err.message); // shows raw technical message, e.g. "Failed to fetch" } try { await submitForm(values); } catch (err) { logError(err); setFormError("We couldn't submit your form. Please check your connection and try again."); } alert(err.message) surfaces whatever raw string the failure happened to produce — often a low-level network error that means nothing to the user — instead of a translated, actionable message, and it doesn't log anything for developers to actually diagnose the failure.
[multiple-choice] Which error category can a React error boundary catch? A. A rejected promise inside an onClick handler B. An error thrown during rendering C. A TypeScript compile-time type error D. An unhandled fetch timeout inside a setTimeout callback
B. An error thrown during rendering — Error boundaries only catch errors thrown during rendering (and in lifecycle methods/constructors) — they do not catch event handler errors, promise rejections, or anything that happens at compile time.
[true-false] In production, it's good practice to show users the raw error.message and stack trace so they can report it precisely. A. True B. False
B. False — Raw technical messages and stack traces are unhelpful to end users and can leak internal implementation details; production UI should show a translated, friendly message while the technical detail goes to logging/monitoring instead.
[multiple-choice] What should differ between how a caught error is handled in development versus production? A. The error-handling code path itself should be entirely different in each environment B. Only the amount of detail shown to the user and/or where it's logged, not the underlying handling logic C. Errors should only be logged in development, never in production D. TypeScript errors should be ignored in production builds
B. Only the amount of detail shown to the user and/or where it's logged, not the underlying handling logic — A robust pattern keeps one handling code path and branches only on verbosity — showing full detail locally for fast debugging, and a safe, friendly message in production while sending full detail to monitoring instead of the DOM.
Write a small function getUserFacingMessage(error: unknown): string that: returns a friendly mapped message if the error is an ApiError (reusing the status-code switch idea above), returns 'This took too long — please try again.' if it's a DOMException named 'AbortError', and falls back to a generic 'Something went wrong. Please try again.' for anything else — without ever returning the raw error.message for an unrecognized error type.
function getUserFacingMessage(error: unknown): string { if (error instanceof ApiError) { return friendlyMessage(error); } if (error instanceof DOMException && error.name === "AbortError") { return "This took too long — please try again."; } return "Something went wrong. Please try again."; }
Use instanceof checks in order (ApiError first, then DOMException with name === 'AbortError'), and end with an unconditional generic fallback string rather than error.message.