Web Development
ReactNext.jsTanStack QuerySWRUXObservabilityGuide

React Error Handling in 2026: Error Boundaries, Retries, and UX Patterns That Scale

AO
Adrijan Omićević
·14 min read

# 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.

  1. 1
    Where should the error be contained so the app stays usable
  2. 2
    Where should the user get a meaningful recovery option

Use this layered map as your default.

LayerCatchesTypical symptomsBest UI response
Component boundaryRender errors in a widget or featureBroken card, chart, editorInline fallback for that component
Route boundaryPage or route segment errorsWhole page failsRoute-level error page with retry and navigation
Data layer retriesTransient network and 5xxSpinners stuck, occasional failuresAutomatic retry then inline error state
User messagingCross-cutting outcomesSave failed, session expiredToast 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 setTimeout callbacks
  • 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.

TSX
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:

TSX
<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
TypeScript
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.

TypeScript
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 classTypical sourceUser messageUI placement
AuthError401, 403“Please sign in again.”Route boundary or modal
NotFoundError404“This item no longer exists.”Inline in details page
RateLimitError429“Too many requests. Try again in a minute.”Inline, sometimes toast
ValidationError422Field-specific messagesInline per field
NetworkErroroffline, timeout“Check your connection and retry.”Inline, sometimes toast
UnknownErrorunexpected“Something went wrong.” plus report optionBoundary 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.

TypeScript
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.

TSX
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

Share
A
Adrijan OmićevićFounder & Senior Developer

Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.

Need help with your project?

We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.