Error handling patterns in React apps
A practical guide to error handling in React: error boundaries, async error catching, query libraries, and how to show users something better than a white screen.

Most React apps handle the happy path beautifully and fall apart the moment a fetch returns a 500. You ship, a user hits an edge case, and suddenly half the screen is blank with a cryptic console error nobody will ever read. Error handling is the unglamorous work that decides whether your app feels solid or fragile, so let's go through the patterns that actually hold up in production.
Why try/catch alone does not cut it
The instinct is to wrap everything in try/catch and call it a day. That works for imperative code, but React rendering is declarative. If a component throws while rendering, a plain try/catch in a parent function will not catch it. React unmounts the whole tree and you get the infamous blank page.
There are really three categories of errors you need to plan for, and each wants a different tool:
- Render errors: a component throws while rendering (reading a property off
undefined, a bad map over null). These need error boundaries. - Async errors: a fetch fails, a promise rejects, a mutation errors out. These live outside React's render cycle, so boundaries do not catch them by default.
- Event handler errors: a click handler throws. React does not catch these either, and honestly that is fine because you usually want to handle them inline.
Mixing these up is the root cause of most "why isn't my error boundary working" questions.
Error boundaries for render errors
Error boundaries are the only built-in mechanism React gives you, and as of React 19 you still need a class component to define one (or a library wrapper). This is one of the rougher edges we run into during React development work. The pattern is stable and worth memorizing.
import { Component, type ReactNode } from "react";
interface Props {
fallback: (error: Error, reset: () => void) => ReactNode;
children: ReactNode;
onError?: (error: Error, info: { componentStack: string }) => void;
}
interface State {
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: { componentStack: string }) {
this.props.onError?.(error, info);
}
reset = () => this.setState({ error: null });
render() {
if (this.state.error) {
return this.props.fallback(this.state.error, this.reset);
}
return this.props.children;
}
}The reset callback matters more than people expect. A fallback UI with a "Try again" button that clears the error state lets users recover without a full page reload, which is the difference between a minor hiccup and a lost session.
Place boundaries at meaningful seams
A single boundary at the root of your app technically works, but it means any error anywhere blanks the entire screen. That is rarely what you want. Put boundaries around independent sections: a dashboard widget, a comments panel, a sidebar. If the comments fail to render, the rest of the page should keep working.
<Layout>
<ErrorBoundary fallback={(e, reset) => <WidgetError onRetry={reset} />}>
<RevenueWidget />
</ErrorBoundary>
<ErrorBoundary fallback={(e, reset) => <WidgetError onRetry={reset} />}>
<ActivityFeed />
</ErrorBoundary>
</Layout>This is the React equivalent of a circuit breaker. One failing component degrades gracefully instead of taking down the whole UI.
Async errors and data fetching
Here is the part that trips people up. If you fetch inside a useEffect and the request fails, the error boundary will not catch it, because the rejection happens after render. You have two honest options.
The first is to manage the error in state yourself:
function useUser(id: string) {
const [state, setState] = useState<
{ status: "loading" } |
{ status: "error"; error: Error } |
{ status: "success"; user: User }
>({ status: "loading" });
useEffect(() => {
let cancelled = false;
setState({ status: "loading" });
fetch(`/api/users/${id}`)
.then((res) => {
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return res.json() as Promise<User>;
})
.then((user) => {
if (!cancelled) setState({ status: "success", user });
})
.catch((error) => {
if (!cancelled) setState({ status: "error", error });
});
return () => {
cancelled = true;
};
}, [id]);
return state;
}Notice two things. The res.ok check is doing real work: fetch does not reject on HTTP error statuses, only on network failures, so a 404 or 500 sails through unless you throw manually. And the cancelled flag prevents setting state after the component unmounts or the id changes mid-flight, which avoids race conditions where a slow stale response overwrites a fresh one.
The discriminated union for state beats juggling three separate isLoading, error, and data booleans, and it is the kind of pattern that pays off across your whole state management approach. You can never end up in an impossible state like "loading and error at the same time," and TypeScript narrows it cleanly in your JSX.
Let a query library do the heavy lifting
Writing that hook by hand once is educational. Writing it fifty times is a chore and a source of subtle bugs. This is where TanStack Query (formerly React Query) earns its keep. It handles caching, retries, cancellation, and crucially it can hand render-time errors back to your error boundary so the two patterns compose.
import { useQuery } from "@tanstack/react-query";
function useUser(id: string) {
return useQuery({
queryKey: ["user", id],
queryFn: async () => {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return res.json() as Promise<User>;
},
retry: 2,
throwOnError: true,
});
}With throwOnError: true, a failed query throws during render, which means your nearest error boundary catches it. Now both your render errors and your data errors flow through the same fallback UI. Pair that with React's Suspense for loading states and the component reads almost entirely as the happy path, with boundaries handling the rest at the edges.
Server errors deserve real shapes
On the Node or Next.js side, do not throw raw strings or leak stack traces to the client. Return a consistent error shape so the frontend can branch on it without parsing English. This is a core habit of designing REST APIs that last.
// app/api/users/[id]/route.ts
export async function GET(_req: Request, { params }: { params: { id: string } }) {
const user = await db.user.findUnique({ where: { id: params.id } });
if (!user) {
return Response.json(
{ error: { code: "USER_NOT_FOUND", message: "User does not exist" } },
{ status: 404 },
);
}
return Response.json({ user });
}A stable code field lets the client distinguish "show a friendly not-found message" from "retry, this was probably transient." Messages change with copy edits; codes are your contract.
What to actually show the user
A good fallback does three things: it tells the user something broke in plain language, it gives them a way out (retry or navigate home), and it quietly reports the error to your monitoring tool so you find out before they email you. Avoid dumping the raw error message into the UI. "Request failed: 500" means nothing to a user and occasionally leaks internal details.
Wire componentDidCatch (or the onError callback above) to Sentry or whatever you use. The componentStack it gives you is gold for debugging, far more useful than a bare stack trace because it tells you which part of the tree blew up.
The takeaway
Match the tool to the error: error boundaries for render failures, explicit state or a query library for async failures, inline handling for events. Place boundaries at section seams so one failure does not blank the screen, give every fallback a retry path, and report errors to monitoring instead of swallowing them. Get these four habits right and your app degrades gracefully instead of dramatically.
If you want a second pair of eyes on your error strategy before it bites you in production, that is the kind of thing we do.
