# Why React Error Handling Needs a Layered System in 2026#
Modern React apps are composable, streaming, and highly asynchronous. That means failures can happen in multiple planes: rendering, routing, network, permissions, and third-party dependencies.
A scalable approach treats error handling like a layered defense, not a single catch-all. Teams that rely on one global toast and one generic fallback tend to ship brittle UX and create observability blind spots.
This guide focuses on React error handling patterns that scale across teams and products by separating concerns:
- Component boundaries for UI failures
- Route boundaries for page-level containment
- Data-fetch retries for transient network issues
- User messaging for clear recovery paths
You will also get reusable patterns and anti-patterns for TanStack Query and SWR, including retry policies and mutation behavior.
# The Layered Model: Where Errors Should Be Caught#
A reliable system answers two questions for every failure.
- 1Where should the error be contained so the app stays usable
- 2Where should the user get a meaningful recovery option
Use this layered map as your default.
| Layer | Catches | Typical symptoms | Best UI response |
|---|---|---|---|
| Component boundary | Render errors in a widget or feature | Broken card, chart, editor | Inline fallback for that component |
| Route boundary | Page or route segment errors | Whole page fails | Route-level error page with retry and navigation |
| Data layer retries | Transient network and 5xx | Spinners stuck, occasional failures | Automatic retry then inline error state |
| User messaging | Cross-cutting outcomes | Save failed, session expired | Toast for global events, inline for form fields |
🎯 Key Takeaway: The goal is not to prevent errors. The goal is to confine blast radius, enable recovery, and keep users moving.
If you are building with Next.js App Router, align this model with route segment error boundaries and loading states. This pairs well with the patterns in Next.js App Router: error boundaries, loading, and streaming patterns.
# Component Error Boundaries That Do Not Ruin the Page#
Error boundaries are still the core React primitive for UI containment. In 2026, the standard is to place boundaries around feature widgets, not around the entire app.
What Error Boundaries Catch and What They Do Not#
Error boundaries catch:
- Errors during rendering
- Errors in constructors
- Errors in lifecycle methods of class components
They do not catch:
- Errors in event handlers
- Async errors in promises or
fetch - Errors inside
setTimeoutcallbacks - Errors on the server unless you rethrow into render on the client
This is why you need a data-layer strategy, not just boundaries.
A Reusable Error Boundary Component#
Keep your boundary small and consistent, and make fallback UI actionable.
import React from "react";
type Props = {
name: string;
fallback: (args: { error: Error; reset: () => void }) => React.ReactNode;
children: React.ReactNode;
onError?: (error: Error) => void;
};
type State = { error: Error | null };
export class ErrorBoundary extends React.Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error) {
this.props.onError?.(error);
}
reset = () => this.setState({ error: null });
render() {
if (this.state.error) {
return this.props.fallback({ error: this.state.error, reset: this.reset });
}
return this.props.children;
}
}Example usage for a chart widget:
<ErrorBoundary
name="RevenueChart"
onError={(e) => console.error("RevenueChart failed", e)}
fallback={({ reset }) => (
<div>
<p>Chart failed to load.</p>
<button onClick={reset}>Try again</button>
</div>
)}
>
<RevenueChart />
</ErrorBoundary>Pattern: Boundary Per Feature, Not Per Page#
A single page often hosts multiple independent features. If one breaks, the others should remain functional. This is especially important in dashboards and admin tools.
A practical rule: one boundary per “widget” that can fail independently. If your page has 6 widgets, you likely want 6 boundaries, not 1.
💡 Tip: Name boundaries after user-facing features, not technical components. Logging becomes searchable and useful when the name matches product language.
Anti-pattern: Catch and Ignore in Render#
Avoid wrapping render logic in try/catch and returning null. It hides the failure from observability and prevents consistent recovery UI.
If something can throw during render, let it throw and be caught by a boundary where you can provide a fallback and log.
# Route Boundaries: Contain Page-Level Failures#
Route boundaries handle errors that prevent a page from rendering at all. In Next.js App Router, this typically maps to route segment error handling. In React Router, you use route-level error elements.
Route boundaries should prioritize:
- Clear message that the page failed
- A retry action
- A path back to a safe location, like dashboard or home
- Minimal noise, maximum recoverability
What Route Boundaries Are For#
Use route boundaries for:
- Authorization gating errors that occur during route load
- Missing critical data that blocks the page
- Unexpected render errors that affect most of the page
Do not use route boundaries for:
- A single widget failing on a page
- Field-level validation
- Background refresh failures
UX Requirements for Route Fallback#
A good route fallback answers these user questions:
- Can I retry
- Is my data safe
- Where can I go instead
A practical template:
- Headline: “This page could not be loaded”
- Body: short and specific if you have a known cause, like “Your session expired”
- Buttons: Retry, Go back, Go to dashboard
- Optional: error code for support, like
ERR_ROUTE_403
# Data-Fetch Errors: Retries That Match Reality#
Most user-visible errors in React apps are network or server issues. Treating all failures as fatal is the fastest way to ship flaky UX.
In production telemetry across consumer and B2B apps, transient failures are common. Real-world CDN and ISP variance means you should expect sporadic timeouts. A conservative design assumption is that a measurable share of requests will fail occasionally under load or during deploys.
Your strategy:
- Retry reads that are safe to retry
- Do not retry writes unless you have idempotency
- Always cap retries and show progress
- Use exponential backoff with jitter to avoid retry storms
TanStack Query: A Production-Ready Retry Policy#
TanStack Query provides fine-grained retry control. A common scalable baseline:
- Retry 2 times for network errors and 5xx
- Do not retry for 4xx except 408 and 429
- Use exponential backoff with a max delay
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error: any) => {
const status = error?.status ?? error?.response?.status;
if (status === 401 || status === 403 || status === 404) return false;
if (status === 429) return failureCount < 3;
if (status >= 500) return failureCount < 2;
return failureCount < 2;
},
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 8000),
staleTime: 30_000,
gcTime: 10 * 60_000,
refetchOnWindowFocus: false,
},
mutations: {
retry: false,
},
},
});This avoids punishing users with long “loading then error” loops. It also reduces backend pressure because retries are capped and delayed.
For caching, pagination, and invalidation strategies that reduce error-prone refetch chains, see React Query cache invalidation, pagination, and mutations at scale.
SWR: Retry With Intent, Not Defaults#
SWR’s defaults can be too aggressive for some products, especially with focus revalidation and unstable networks. Set explicit policies.
import useSWR from "swr";
const fetcher = (url: string) => fetch(url).then((r) => {
if (!r.ok) {
const err: any = new Error("Request failed");
err.status = r.status;
throw err;
}
return r.json();
});
export function useCustomers() {
return useSWR("/api/customers", fetcher, {
revalidateOnFocus: false,
shouldRetryOnError: (err) => {
if (err?.status === 401 || err?.status === 403 || err?.status === 404) return false;
return true;
},
errorRetryCount: 2,
errorRetryInterval: 1000,
dedupingInterval: 2000,
});
}Anti-pattern: Retrying Mutations Without Idempotency#
If you retry a mutation like “Create invoice” after a timeout, you can create duplicates. This is not theoretical. It happens under weak mobile networks and during backend deploys.
If you must retry writes:
- Use server-side idempotency keys
- Return stable identifiers
- Show a “processing” state rather than firing again
⚠️ Warning: Disabling retries for mutations is safer than enabling them blindly. If you cannot guarantee idempotency, a retry is a data integrity bug, not a UX feature.
# Turning Data Errors Into UX: Inline, Toasts, and Recovery Flows#
A scalable UX pattern distinguishes between local and global errors.
- Inline messaging is best when the error relates to a specific UI region or form field.
- Toasts are best for cross-cutting events that happen in the background or as a result of user actions that are already complete.
Inline Errors: Best for Forms, Panels, and Widgets#
Inline messages should include:
- What failed in plain language
- A next action, like retry or refresh
- Optional detail if it helps, like “Check your connection”
A widget error pattern:
- Title: “Could not load customers”
- Button: Retry
- Secondary: “View cached data” if available
Toasts: Best for Save Outcomes and Background Jobs#
Toasts work when they are:
- Brief
- Actionable
- Not repeated in loops
Examples that scale:
- “Saved” with an Undo action
- “Upload failed” with Retry
- “Session expired” with Sign in
Anti-pattern: showing a toast on every background refetch failure. Users experience “toast spam” and start ignoring it.
Pattern: Message Taxonomy and Error Codes#
Create a shared error taxonomy so that errors are consistent across teams.
| Error class | Typical source | User message | UI placement |
|---|---|---|---|
| AuthError | 401, 403 | “Please sign in again.” | Route boundary or modal |
| NotFoundError | 404 | “This item no longer exists.” | Inline in details page |
| RateLimitError | 429 | “Too many requests. Try again in a minute.” | Inline, sometimes toast |
| ValidationError | 422 | Field-specific messages | Inline per field |
| NetworkError | offline, timeout | “Check your connection and retry.” | Inline, sometimes toast |
| UnknownError | unexpected | “Something went wrong.” plus report option | Boundary fallback |
This taxonomy makes it easier to decide whether to retry and where to show the message.
# A Practical Implementation: One Error Model Used Everywhere#
The fastest path to consistent behavior is to normalize errors at the API client layer.
Normalize Fetch Errors Into a Typed Shape#
Keep the shape simple and serializable.
export type AppError = {
name: string;
message: string;
status?: number;
code?: string;
retriable?: boolean;
};
export async function api<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init);
if (!res.ok) {
const err: AppError = {
name: "ApiError",
message: "Request failed",
status: res.status,
retriable: res.status >= 500 || res.status === 429,
};
throw err;
}
return res.json() as Promise<T>;
}Now your TanStack Query or SWR code can base retry and UI logic on status and retriable.
Pattern: Throw on Render Only for “Must Have” Data#
If your component cannot render without data, you can throw from render to let an error boundary catch it, but only if the error is already in memory.
Example concept:
- Data hook returns an error object
- If the screen cannot function, throw that error in render
- Otherwise show an inline error state
This avoids mixing boundary logic into every child component.
ℹ️ Note: Error boundaries still will not catch promise rejections automatically. The hook must already be in an error state during render for the throw pattern to work.
# Anti-Patterns That Keep Showing Up in TanStack Query and SWR#
These patterns create flaky UX and make incidents harder to debug.
Anti-pattern 1: Using onError to Show a Toast for Every Query#
If a list refetches every 30 seconds, you will spam users during a partial outage. Prefer inline states and only toast on user-triggered actions.
Better pattern:
- Queries render inline error UI inside the affected section
- Mutations show toasts because the user initiated the action
Anti-pattern 2: Treating 404 as “Error” Everywhere#
In many products, 404 is a valid outcome, like “No results” or “Item deleted.” Map it to an empty state when appropriate.
Example:
- Customer not found in a details page should show a “Customer not found” screen with navigation
- Search results 404 should be “No results” rather than an error
Anti-pattern 3: Infinite Retries with Focus Revalidation#
Default focus revalidation plus retries can create a loop:
- User switches tabs
- App refetches
- Server returns 500
- Retry triggers
- User switches tabs again
- More refetches
Fix it with explicit settings and caps, as shown earlier.
Anti-pattern 4: Clearing the Cache on Any Error#
Some teams call queryClient.clear() during auth errors. This is disruptive and can trigger cascades of refetches. Prefer targeted invalidation and redirect flows.
If you need to understand invalidation patterns that avoid cascading failures, use this React Query scale guide.
# Testing Error Handling: Make Failures a First-Class Scenario#
You cannot trust error handling you do not test. A practical baseline is to test:
- Widget boundary fallback renders when child throws
- Route boundary renders on loader or page failure
- Query retry behavior is capped
- Inline error messages appear and retry works
- Mutation failures show a single toast, not a loop
A reliable toolchain is Vitest, React Testing Library, and MSW for network mocking. The workflow is covered in React testing strategy with Vitest, React Testing Library, and MSW.
Example: test a component boundary fallback.
import { render, screen } from "@testing-library/react";
import React from "react";
import { ErrorBoundary } from "./ErrorBoundary";
function Boom() {
throw new Error("boom");
}
it("shows fallback when child throws", () => {
render(
<ErrorBoundary
name="Test"
fallback={() => <div>Fallback</div>}
>
<Boom />
</ErrorBoundary>
);
expect(screen.getByText("Fallback")).toBeInTheDocument();
});# Operational Concerns: Logging, Correlation, and Supportability#
Good UX is not enough. You also need fast debugging.
Minimum operational requirements:
- Log errors from boundaries with context, like feature name and route
- Include a correlation ID from backend responses if available
- Record whether the user clicked retry and whether it succeeded
This turns “it crashed” into a tractable incident.
A pragmatic approach is to attach metadata in your boundary onError and data fetching clients, then forward it to your observability stack. Keep the metadata stable so dashboards are usable.
# Key Takeaways#
- Place component error boundaries around independent widgets so one failure does not take down the whole page.
- Use route boundaries for page-level failures and provide retry plus safe navigation options.
- Configure data retries intentionally: retry idempotent reads with caps and backoff, avoid mutation retries unless you have idempotency.
- Prefer inline error states for local problems and toasts for user-triggered outcomes and cross-cutting events.
- Standardize on an error taxonomy and normalized error shape so retry logic and UX are consistent across teams.
# Conclusion#
React apps in 2026 fail in more places than rendering, so scalable reliability comes from layered React error handling patterns: boundaries for UI containment, route-level fallbacks for page recovery, data-layer retries for transient failures, and UX messaging that stays calm and actionable.
If you want help implementing these patterns across a Next.js or React codebase, Samioda can audit your current error flows, unify retry policies for TanStack Query or SWR, and ship a consistent fallback and messaging system that improves both UX and incident response. Reach out via Samioda to get a practical plan and a production-ready implementation.
FAQ
Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.
More in Web Development
All →Next.js + Supabase Edge Functions: A Practical Architecture for Modern SaaS (2026 Guide)
A production-ready Next.js Supabase Edge Functions architecture for SaaS: what runs in Edge Functions vs API routes/server actions, how to structure modules, and how to deploy on Vercel or Cloudflare safely.
React Component Contract Testing: MSW + Storybook as Living API Mocks
A practical guide to React component contract testing using shared MSW handlers across Storybook and automated tests to prevent mock drift, flaky UI, and regressions in CI.
React Data Table Patterns at Scale: Virtualization, Column Pinning, Filters, and Export (TanStack Table + Virtual)
Production-ready React data table patterns using TanStack Table and TanStack Virtual: state architecture, server-side pagination and sorting, debounced filters, column pinning, exports, and performance pitfalls.
Need help with your project?
We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.
Related Articles
Next.js App Router Forms in 2026: React Hook Form + Zod + Server Actions (Validation, Errors, UX)
A production-ready architecture for Next.js App Router forms using React Hook Form, Zod, and Server Actions — with shared schemas, secure server-side validation, async checks, file uploads, and consistent error handling.
Next.js App Router UX Patterns: Error Boundaries, Loading UI, and Streaming Done Right
A practical guide to resilient UX in Next.js App Router: route segment structure, error.tsx, loading.tsx, not-found.tsx, and Suspense streaming patterns for partial rendering, safer data fetching, and fewer layout shifts.
React Query at Scale: Cache Invalidation, Pagination, and Mutation Patterns for Real Apps
React Query cache invalidation best practices for real-world apps: scalable query key design, invalidation strategy, optimistic updates, infinite queries, and background refetching in Next.js App Router.