Error Handling in React: What Users Should See When Things Break

The first serious error-handling conversation on the insurance CRM happened after a demo.
We had built the policy detail page. It fetched the policy, loaded the customer, showed coverage sections, rendered a timeline, and pulled a list of recent changes. The happy path looked good. Fast enough. Clean enough. The kind of screen that makes a product manager relax for about five minutes.
Then the staging API returned a 500 for the timeline.
The whole page disappeared.
Not the timeline. Not the lower section. The entire policy detail page. A blank error screen replaced the policy number, the customer information, the coverage details, everything the agent actually needed to keep working.
The backend bug was real. But the frontend decision was worse: we had treated one failed widget as if the whole route was unusable.
I've watched this happen in multiple React apps. Error handling gets added late, after the components are built and the data flow already exists. Someone wraps the app in an Error Boundary, adds a few isError checks, maybe logs to Sentry, and calls it done.
It is not done.
Error handling is not just catching failures. It is deciding the blast radius of a failure.
A good error boundary is not a wall around your app. It is a circuit breaker around the smallest thing that can safely fail.
This article continues the React Playbook foundation. We already covered the stack, TypeScript patterns, and code structure. Now we need to talk about what happens when the happy path breaks: React Error Boundaries, TanStack Query error states, TanStack Router route errors, mutation failures, and the messages users actually deserve.
First, Separate Error Types
Most frontend error handling gets messy because teams put every failure in the same mental bucket.
They are not the same.
In a React app, I separate five categories:
- Render errors — a component throws while rendering.
- Route loading errors — a route cannot load the data required to show the page.
- Query errors — a specific async resource failed.
- Mutation errors — a user action failed.
- Validation errors — user input is invalid before the request should happen.
Each one needs a different UX.
A render error is usually unexpected. You need containment, logging, and a recovery path.
A route loading error might mean the page cannot exist. If /policies/$policyId cannot load the policy at all, the page cannot show much.
A query error might only affect part of the screen. If recent activity fails, the user can still read the policy.
A mutation error happens after intent. The user clicked Save, Submit, Assign, Delete. The UI needs to explain what happened and preserve their work.
A validation error is not exceptional at all. It is part of the form flow.
When you model these separately, the code gets simpler. More importantly, the product gets calmer.
App-Level Error Boundary
React Error Boundaries catch errors thrown during rendering, lifecycle methods, and constructors of child components. They do not catch async errors from promises by default. TanStack Query and Router have their own integration points, which we'll get to.
For the app shell, you still want a top-level boundary:
// src/app/AppErrorBoundary.tsx import { ErrorBoundary } from 'react-error-boundary'; interface AppErrorFallbackProps { error: Error; resetErrorBoundary: () => void; } function AppErrorFallback({ error, resetErrorBoundary }: AppErrorFallbackProps) { return ( <main className="mx-auto flex min-h-screen max-w-xl flex-col justify-center p-6"> <h1 className="text-2xl font-semibold">Something went wrong</h1> <p className="mt-3 text-muted-foreground"> The application hit an unexpected error. You can try again, or refresh the page if the problem continues. </p> <pre className="mt-4 rounded-md bg-muted p-3 text-sm">{error.message}</pre> <button className="mt-6" onClick={resetErrorBoundary}> Try again </button> </main> ); } export function AppErrorBoundary({ children }: { children: React.ReactNode }) { return ( <ErrorBoundary FallbackComponent={AppErrorFallback} onError={(error, info) => { reportError(error, { componentStack: info.componentStack }); }} > {children} </ErrorBoundary> ); }
Then wire it at the application edge:
// src/app/providers.tsx import { QueryClientProvider } from '@tanstack/react-query'; import { AppErrorBoundary } from './AppErrorBoundary'; import { queryClient } from './query-client'; export function AppProviders({ children }: { children: React.ReactNode }) { return ( <AppErrorBoundary> <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> </AppErrorBoundary> ); }
This is the last line of defense. It should almost never be the boundary your users see during normal failures.
If a single chart crashes, don't replace the entire app. If a sidebar widget crashes, don't hide the working page. Put smaller boundaries around risky areas.
Section-Level Boundaries
The policy detail page had several independent sections:
- policy header
- coverage summary
- beneficiaries
- recent activity timeline
- document attachments
The timeline was useful, but not essential. A timeline failure should not kill the route.
So give it a local boundary:
// src/widgets/recent-policy-activity/RecentPolicyActivityBoundary.tsx import { ErrorBoundary } from 'react-error-boundary'; import { RecentPolicyActivity } from './RecentPolicyActivity'; function RecentPolicyActivityFallback() { return ( <section className="rounded-md border p-4"> <h2 className="font-medium">Recent activity</h2> <p className="mt-2 text-sm text-muted-foreground"> Activity could not be loaded right now. The policy details are still available. </p> </section> ); } export function RecentPolicyActivityBoundary({ policyId }: { policyId: string }) { return ( <ErrorBoundary FallbackComponent={RecentPolicyActivityFallback}> <RecentPolicyActivity policyId={policyId} /> </ErrorBoundary> ); }
The key sentence in the fallback is not "An error occurred." The key sentence is: the policy details are still available.
Users need to know scope. What broke? What still works? What can they do next?
TanStack Query: Local Error States First
TanStack Query gives you the query state directly:
const policyQuery = useQuery(policyDetailQueryOptions(policyId)); if (policyQuery.isPending) return <PolicyDetailSkeleton />; if (policyQuery.isError) return <PolicyDetailError error={policyQuery.error} />; return <PolicyDetailView policy={policyQuery.data} />;
This is the right pattern when the query owns the whole component. The component has one main job: load policy detail and render it. If that query fails, the component cannot do its job.
Make the error component specific:
// src/entities/policy/ui/PolicyDetailError.tsx interface PolicyDetailErrorProps { error: Error; onRetry: () => void; } export function PolicyDetailError({ error, onRetry }: PolicyDetailErrorProps) { return ( <div className="rounded-md border border-destructive/30 p-4"> <h2 className="font-medium">Policy could not be loaded</h2> <p className="mt-2 text-sm text-muted-foreground"> The policy may have been removed, or the server may be temporarily unavailable. </p> <p className="mt-2 text-xs text-muted-foreground">{error.message}</p> <button className="mt-4" onClick={onRetry}> Retry </button> </div> ); }
Then pass the retry:
if (policyQuery.isError) { return <PolicyDetailError error={policyQuery.error} onRetry={() => policyQuery.refetch()} />; }
That refetch() matters. A dead error state with no recovery action forces the user to refresh the browser. That is not error handling. That is surrender.
Configure Query Defaults Deliberately
In article #1, we started with:
// src/app/query-client.ts import { QueryClient } from '@tanstack/react-query'; export const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60 * 5, retry: 1, }, }, });
retry: 1 is not random. The TanStack Query default is three retries with exponential backoff. For some apps, that is fine. For internal CRM workflows, I usually prefer one retry.
Why?
Because three retries can make an error feel like a frozen screen. The user clicked a policy. The API is failing. The UI waits, retries, waits, retries, waits again, and only then shows an error. Technically resilient. Product-wise frustrating.
But not every query deserves the same retry behavior.
export function policyDetailQueryOptions(policyId: string) { return queryOptions({ queryKey: policyKeys.detail(policyId), queryFn: () => fetchPolicyById(policyId), retry: (failureCount, error) => { if (isNotFoundError(error)) return false; return failureCount < 1; }, }); }
Don't retry 404s. The policy isn't going to appear after a second attempt. Do retry one transient network failure. That is a reasonable default.
This is where typed API errors help.
Typed API Errors
In article #2, we talked about typing API responses at the boundary. Do the same for errors.
// src/shared/api/api-error.ts export class ApiError extends Error { constructor( message: string, public readonly status: number, public readonly code?: string, ) { super(message); this.name = 'ApiError'; } } export function isApiError(error: unknown): error is ApiError { return error instanceof ApiError; } export function isNotFoundError(error: unknown) { return isApiError(error) && error.status === 404; }
Use it in the HTTP client:
// src/shared/api/http-client.ts import { ApiError } from './api-error'; export async function getJson<T>(url: string): Promise<T> { const response = await fetch(url); if (!response.ok) { const body = await response.json().catch(() => null); throw new ApiError(body?.message ?? `GET ${url} failed`, response.status, body?.code); } return response.json() as Promise<T>; }
Now your UI can make product decisions:
function PolicyDetailError({ error, onRetry }: PolicyDetailErrorProps) { if (isNotFoundError(error)) { return ( <EmptyState title="Policy not found" description="This policy may have been deleted or you may not have access to it." /> ); } return ( <ErrorState title="Policy could not be loaded" description="The server did not return the policy. Try again in a moment." action={{ label: 'Retry', onClick: onRetry }} /> ); }
A 404 and a 500 should not have the same message. One is probably permanent. One may be temporary. The UI should reflect that.
Route-Level Errors with TanStack Router
Some data is required before a route can render.
For a policy detail page, the policy itself is required. Recent activity is not. Attachments are not. Recommendations are not.
TanStack Router lets you load required data at the route level:
// src/pages/policy-detail/policy-detail.route.ts export const policyDetailRoute = createRoute({ getParentRoute: () => rootRoute, path: '/policies/$policyId', loader: ({ context, params }) => context.queryClient.ensureQueryData(policyDetailQueryOptions(params.policyId)), errorComponent: PolicyDetailRouteError, component: PolicyDetailPage, });
Then the route error component owns route-level failures:
// src/pages/policy-detail/PolicyDetailRouteError.tsx import { useRouter } from '@tanstack/react-router'; import { isNotFoundError } from '@/shared/api'; export function PolicyDetailRouteError({ error }: { error: unknown }) { const router = useRouter(); if (isNotFoundError(error)) { return ( <RouteErrorLayout title="Policy not found" description="The policy does not exist, was removed, or is not available to your account." action={{ label: 'Back to policies', to: '/policies' }} /> ); } return ( <RouteErrorLayout title="Policy could not be opened" description="Something failed while loading this policy. You can retry without leaving the page." action={{ label: 'Retry', onClick: () => router.invalidate() }} /> ); }
router.invalidate() is the route-level retry. It re-runs loaders and gives the route a chance to recover.
The important design choice is deciding what belongs in the loader. If every widget query goes into the loader, every widget failure becomes a route failure. That is exactly how we blanked the policy detail page in the demo.
My rule:
Load the minimum data required to make the route meaningful. Everything else should fail locally.
Mutation Errors Are Different
Query errors happen while reading.
Mutation errors happen after the user did something.
That difference matters. A failed mutation should preserve the user's input, explain what happened, and make retry obvious.
// src/features/edit-policy-status/ui/EditPolicyStatusForm.tsx export function EditPolicyStatusForm({ policyId }: { policyId: string }) { const editStatus = useEditPolicyStatus(); return ( <form onSubmit={(event) => { event.preventDefault(); editStatus.mutate({ policyId, status: 'active' }); }} > {editStatus.isError && ( <InlineError> Status could not be updated. Your change was not saved. Try again in a moment. </InlineError> )} <button disabled={editStatus.isPending}> {editStatus.isPending ? 'Saving...' : 'Save status'} </button> </form> ); }
Notice the wording: Your change was not saved.
Users need to know whether the action happened. Ambiguous mutation errors are dangerous. Did the payment submit? Did the policy update? Did the customer receive the email? If the system cannot guarantee success, say so clearly.
For destructive actions, be even more explicit:
<InlineError> The document was not deleted. The server rejected the request, so it is still attached to the policy. </InlineError>
That kind of sentence sounds small. In operational software, it matters.
Optimistic Updates Need Rollback
TanStack Query makes optimistic updates straightforward, but optimistic error handling has to be designed.
// src/features/edit-policy-status/api/edit-policy-status.mutation.ts export function useEditPolicyStatus() { const queryClient = useQueryClient(); return useMutation({ mutationFn: updatePolicyStatus, onMutate: async (variables) => { await queryClient.cancelQueries({ queryKey: policyKeys.detail(variables.policyId) }); const previousPolicy = queryClient.getQueryData<PolicyDetail>( policyKeys.detail(variables.policyId), ); queryClient.setQueryData<PolicyDetail>(policyKeys.detail(variables.policyId), (policy) => policy ? { ...policy, status: variables.status } : policy, ); return { previousPolicy }; }, onError: (_error, variables, context) => { queryClient.setQueryData(policyKeys.detail(variables.policyId), context?.previousPolicy); }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: policyKeys.detail(variables.policyId) }); }, }); }
Optimistic UI is a promise: "we'll show the result now and clean it up if the server disagrees."
If you don't roll back, it becomes a lie.
Honestly, I use optimistic updates less often than people expect. They are excellent for low-risk interactions: toggles, lightweight status changes, local ordering. For payments, legal records, irreversible actions, or anything with compliance implications, I prefer boring pending states and clear confirmation.
Fast is good. Correct is better.
Validation Errors Are Not Exceptions
Form validation errors should not go through Error Boundaries, route error components, or global toasts.
They belong next to the field or section that needs correction.
// src/features/create-policy/ui/CreatePolicyForm.tsx <form.Field name="holderName" validators={{ onBlur: ({ value }) => { if (!value.trim()) return 'Policy holder name is required'; return undefined; }, }} > {(field) => ( <TextField label="Policy holder" value={field.state.value} onBlur={field.handleBlur} onChange={(event) => field.handleChange(event.target.value)} error={field.state.meta.errors[0]} /> )} </form.Field>
A validation message is not an apology. It is guidance.
Bad:
Invalid value.
Better:
Policy holder name is required.
Better still, when the rule is domain-specific:
Coverage start date must be after the policy approval date.
The closer the error is to the user's mental model, the less support burden you create.
Global Toasts Are Not an Error Strategy
I have a strong bias here: global error toasts are overused.
They are fine for background actions:
Report export failed. Try again later.
They are not fine as the only feedback for a failed form submit, failed route load, or failed section query.
Toasts disappear. They are hard to reread. They often don't say what changed. If the user needs the information to continue, put it in the interface where the failure happened.
Use toasts for secondary notifications. Use inline states for primary failures.
Logging Without Leaking
You still need error reporting.
But don't send everything everywhere. A useful report has context:
// src/shared/lib/report-error.ts interface ErrorContext { route?: string; policyId?: string; componentStack?: string; action?: string; } export function reportError(error: unknown, context: ErrorContext = {}) { if (import.meta.env.DEV) { console.error(error, context); return; } // Send to your reporting tool here. // Include context, but avoid sensitive policy/customer data. }
For a CRM, this matters. Policy numbers, customer names, payment details, medical information, internal notes — these should not casually land in logs because a component threw.
Log identifiers when needed. Avoid payloads unless you have a reason and a retention policy.
A Practical Error-Handling Checklist
When I review a React screen, I ask these questions:
- What data is required for the route to be meaningful?
- What data can fail locally without killing the page?
- Does every failed query have a retry path?
- Does every failed mutation preserve user input?
- Does the user know whether their action was saved?
- Are 404, 403, 409, and 500 treated differently where it matters?
- Are validation errors close to the fields?
- Are unexpected errors logged with useful context and without sensitive data?
This is not bureaucracy. This is how you keep production software calm.
What Comes Next
The first four articles now cover the base layer of a serious React application:
- Article #1: the stack
- Article #2: TypeScript patterns
- Article #3: code structure
- Article #4: this error-handling model
Article #5: data fetching with TanStack Query — query keys, cache shape, prefetching, invalidation, stale time, pagination, and the boring details that decide whether a React app feels reliable or twitchy.
After that staging demo, we changed the policy detail page. The policy itself stayed route-level. The timeline moved into a local query with its own fallback. Attachments got a retry button. Mutations kept user input visible after failure. The app did not become immune to backend problems. No frontend can do that.
But failures became smaller.
That is the whole point. Not hiding errors. Not pretending the system is fine. Making sure one broken piece does not take the rest of the work down with it.
If you're designing error handling for a React app and every failure currently becomes either a toast or a blank screen, reach out. There is usually a calmer architecture hiding one boundary lower.