EmpireUI
Get Pro
← Blog8 min read#error boundary#react#error handling

React Error Boundaries in 2026: Class, Hooks-Compat, Recovery UI

Error boundaries still matter in 2026 — here's how to write them cleanly, skip the class boilerplate, and build recovery UI that actually helps users.

developer debugging React error boundary component on dark monitor

Why Error Boundaries Still Matter (and Why Most Apps Get Them Wrong)

Error boundaries aren't glamorous. Nobody puts them in a portfolio. But ship a React app without them and you'll find out quickly — one unhandled render error wipes out your entire UI and the user sees a blank white page with zero context. That's worse than an explicit error screen. Way worse.

React 18 didn't deprecate class-based error boundaries. React 19 didn't either. In 2026, getDerivedStateFromError and componentDidCatch are still the *only* built-in way to catch render-time exceptions in a component tree. Hooks can't do it — useEffect doesn't catch synchronous render errors, and there's no useErrorBoundary in React core yet. Worth noting: there is a stage-2 TC39 proposal for hook-level error catching, but it hasn't shipped in any stable React release at the time of writing.

In practice, most teams either skip error boundaries entirely (risky), put one giant boundary at the app root (better than nothing, but you lose granularity), or reach for a third-party wrapper like react-error-boundary v5 to get a cleaner API. All three approaches have legitimate uses — it depends on what you're building and how much control you need over recovery behaviour.

Honestly, the tooling around error boundaries has gotten good enough in 2026 that there's no excuse for skipping them. Let's look at all three patterns and when to reach for each.

The Classic Class Component Pattern (Still Valid)

Yes, it's a class component. No, that doesn't make it legacy. React's own docs still recommend this pattern, and it works fine inside an otherwise hooks-driven codebase — you're not forced to convert everything around it.

Here's the minimal viable error boundary you'd write from scratch in 2026: ``tsx // ErrorBoundary.tsx import { Component, ReactNode } from 'react'; interface Props { children: ReactNode; fallback?: ReactNode; } interface State { hasError: boolean; error: Error | null; } export class ErrorBoundary extends Component<Props, State> { state: State = { hasError: false, error: null }; static getDerivedStateFromError(error: Error): State { return { hasError: true, error }; } componentDidCatch(error: Error, info: { componentStack: string }) { // Send to your error tracker here console.error('[ErrorBoundary]', error, info.componentStack); } render() { if (this.state.hasError) { return this.props.fallback ?? ( <div role="alert" className="p-6 rounded-xl border border-red-500/30 bg-red-500/10"> <p className="text-red-400 font-medium">Something went wrong.</p> <button className="mt-3 text-sm underline" onClick={() => this.setState({ hasError: false, error: null })} > Try again </button> </div> ); } return this.props.children; } } ``

The getDerivedStateFromError static method is what flips your component into error mode — it runs synchronously during the render phase and must return new state. componentDidCatch runs after the tree has committed and is where you'd fire your Sentry or Datadog call. Keep them separate. Don't try to do side-effects inside getDerivedStateFromError.

Quick aside: the onClick reset trick above — calling setState to clear the error — is the simplest recovery mechanism you can ship. It re-renders the subtree from scratch. If the error was transient (a network blip inside a render, a race condition), that retry often just works.

Wrap this around any subtree that represents a discrete UI region. A sidebar, a data widget, a payment form. Not the entire app — that's too coarse. If a chart crashes, you want the rest of the dashboard to stay alive.

Using react-error-boundary v5 for a Hooks-Compatible API

react-error-boundary by Brian Vaughn (now maintained by the React team ecosystem) gives you a hooks-friendly surface without reinventing the class pattern yourself. Version 5 ships with React 19 support, a useErrorBoundary hook for *programmatic* error triggering, and a withErrorBoundary HOC for legacy codebases.

Install it once and stop writing class components for this: ``bash npm install react-error-boundary@5 ` Then use it like this: `tsx import { ErrorBoundary } from 'react-error-boundary'; function DashboardWidget() { return ( <ErrorBoundary fallbackRender={({ error, resetErrorBoundary }) => ( <div role="alert" className="rounded-2xl p-6 bg-neutral-900 border border-white/10"> <p className="text-sm text-red-400">{error.message}</p> <button className="mt-4 px-4 py-2 text-xs bg-white/10 rounded-lg hover:bg-white/20" onClick={resetErrorBoundary} > Reload widget </button> </div> )} onError={(error, info) => logToSentry(error, info)} > <ExpensiveDataChart /> </ErrorBoundary> ); } ``

The fallbackRender prop (note: not fallback — that's a static element) gives you access to the error *and* a resetErrorBoundary function that clears state and re-renders. That's the key feature over rolling your own class — you don't have to wire up a setState callback and thread it through props.

That said, useErrorBoundary inside a child component is genuinely useful for async errors. Say you're in a useEffect fetching data and you want the *boundary* above you — not a local error state — to handle it: ``tsx import { useErrorBoundary } from 'react-error-boundary'; function DataLoader() { const { showBoundary } = useErrorBoundary(); useEffect(() => { fetchCriticalData() .then(setData) .catch(showBoundary); // propagate to nearest boundary }, []); return <div>{/* render data */}</div>; } `` This bridges the gap between async errors and render-tree error handling — something the raw React API doesn't cover at all.

Building Recovery UI That Actually Helps

Your fallback UI is the thing the user sees when something breaks. Most teams ship <p>Something went wrong.</p> and call it a day. That's not good enough — especially if you care about retention. What can the user actually *do* from that screen?

Think in three tiers. First: a retry button (always). Second: a return to safety link — home page, dashboard root, wherever makes sense. Third: if you have user data in context, surface something helpful like 'Your draft was saved' or a status indicator so they're not left wondering what broke.

Here's a more complete recovery card you could drop into any project. It works well over a dark gradient background — you could even pair it with the styling from Empire UI's glassmorphism components to keep it visually consistent with your app's design language: ``tsx function RecoveryFallback({ error, resetErrorBoundary, }: { error: Error; resetErrorBoundary: () => void; }) { return ( <div role="alert" className="flex flex-col items-center justify-center min-h-64 gap-4 rounded-2xl border border-white/10 bg-white/5 backdrop-blur-md p-8 text-center" > <span className="text-3xl">⚠️</span> <h2 className="text-lg font-semibold text-white">Widget failed to load</h2> <p className="text-sm text-white/50 max-w-xs"> {error.message || 'An unexpected error occurred in this section.'} </p> <div className="flex gap-3 mt-2"> <button onClick={resetErrorBoundary} className="px-4 py-2 rounded-lg bg-white text-black text-sm font-medium hover:bg-white/90 transition-colors" > Try again </button> <a href="/" className="px-4 py-2 rounded-lg border border-white/20 text-white text-sm hover:bg-white/10 transition-colors" > Go home </a> </div> </div> ); } ``

One more thing — if your app has a design system with specific error states (toast styles, icon sets, color tokens), your error boundary fallback should pull from the same system. Mismatched error UI is jarring. Empire UI's box shadow generator and component primitives make it easy to keep the visual language consistent even in failure states.

Log the error, always. Whether you're using Sentry, Datadog, LogRocket, or a custom endpoint — componentDidCatch or the onError prop in react-error-boundary is where you fire that. Don't swallow errors silently. You can't fix what you can't see.

Granularity: Where to Actually Place Your Boundaries

One boundary at the root isn't a strategy. A crash in your <Sidebar /> shouldn't blank out the main content area. A crash in a third-party chart library shouldn't take down your navigation. You want isolation — and that means placing boundaries at logical seams.

Look, here's a practical placement heuristic. If removing a component from the page wouldn't break the core task the user came to do, wrap it in its own boundary. Charts, widgets, comment sections, ad units, third-party embeds — all good candidates. Navigation, routing, auth context — those failures probably warrant a full-page fallback anyway.

In Next.js App Router (the default in 2026), you can co-locate error boundaries with the file system using error.tsx files. Drop an error.tsx next to any page.tsx and Next.js automatically wraps that route segment: ``tsx // app/dashboard/error.tsx 'use client'; import { useEffect } from 'react'; export default function DashboardError({ error, reset, }: { error: Error & { digest?: string }; reset: () => void; }) { useEffect(() => { logToSentry(error); }, [error]); return ( <div className="flex flex-col items-center gap-4 py-24"> <p className="text-red-400">The dashboard crashed.</p> <button onClick={reset} className="px-4 py-2 bg-white/10 rounded-lg text-sm"> Retry </button> </div> ); } ``

The reset function from Next.js does the same thing as resetErrorBoundary — it clears the error state and re-renders the route segment. The digest field is a Next.js-specific hash that links the client error to server logs, which is useful when you're debugging a production crash and the actual message is redacted for security.

How granular is too granular? If you're wrapping every individual button, you've gone too far. The overhead is minimal, but the cognitive load of managing dozens of nested boundaries outweighs the benefit. Aim for 3-8 boundaries in a typical dashboard app — one per major region, not per component.

Testing Error Boundaries Without Losing Your Mind

Testing error boundaries is annoying because React swallows errors in test environments and logs them to console.error regardless. Your test output fills up with red noise even when the test is passing. The fix is to mock console.error before the test and restore it after: ``tsx // ErrorBoundary.test.tsx import { render, screen, fireEvent } from '@testing-library/react'; import { ErrorBoundary } from './ErrorBoundary'; const Bomb = () => { throw new Error('Kaboom'); }; describe('ErrorBoundary', () => { let consoleError: jest.SpyInstance; beforeEach(() => { consoleError = jest .spyOn(console, 'error') .mockImplementation(() => {}); }); afterEach(() => consoleError.mockRestore()); it('renders fallback on error', () => { render( <ErrorBoundary fallback={<p>Error occurred</p>}> <Bomb /> </ErrorBoundary> ); expect(screen.getByText('Error occurred')).toBeInTheDocument(); }); it('resets when retry is clicked', () => { let shouldThrow = true; const Flaky = () => { if (shouldThrow) throw new Error('Flaky'); return <p>Recovered</p>; }; render(<ErrorBoundary><Flaky /></ErrorBoundary>); shouldThrow = false; fireEvent.click(screen.getByText('Try again')); expect(screen.getByText('Recovered')).toBeInTheDocument(); }); }); ``

The Bomb component pattern — a component that just throws — is the standard way to trigger boundary behavior in tests. Simple, explicit, predictable. You can parameterise it to throw different error types if you need to test different fallback branches.

For Cypress or Playwright end-to-end tests, you'll want to test the *user-visible* recovery flow rather than unit testing the boundary itself. Navigate to a page, intercept a network request to make it fail in a way that causes a render error, then assert the recovery UI shows up and the retry button works. That's the test that actually proves your boundary is wired up correctly in context.

Error Boundaries and the Wider React Error Story

Error boundaries only catch render errors — synchronous exceptions thrown during render, lifecycle methods, and constructor calls. They do *not* catch errors in event handlers, async code, or server components. That's a common source of confusion.

For async errors in event handlers, you still need try/catch or .catch() chains and local state or a toast notification. Something like: ``tsx async function handleSubmit() { try { await submitForm(data); } catch (err) { setFormError(err instanceof Error ? err.message : 'Submission failed'); } } `` That's not a boundary concern — it's business logic error handling. Keep them separate.

React Server Components (RSC) in Next.js have their own error handling via error.tsx on the server-rendered side. But client component boundaries still apply to anything in the 'use client' subtree. In 2026, most production Next.js apps have a split — server-rendered shells with client islands — so you need to think about both layers.

If you're building something visually ambitious — an app that uses Empire UI's gradient generator for dynamic backgrounds, or an interactive template with animated surfaces — don't let the visual polish distract you from the error handling foundation. A beautiful UI that blanks on an unhandled exception is less impressive than a slightly simpler one that recovers gracefully. Ship the boundary first, then ship the style.

The honest takeaway: error boundaries are unsexy infrastructure. They take maybe 30 minutes to add properly across a medium-sized app. The return — stable UIs that recover instead of crash — is enormous relative to that cost. Set up a root boundary, add granular ones at your major regions, use react-error-boundary v5 to get the hooks integration for free, and move on. You've got bigger problems to solve.

FAQ

Can you use hooks inside an error boundary component?

Not to catch render errors — that still requires a class with getDerivedStateFromError. But react-error-boundary v5 gives you useErrorBoundary() in *child* components to programmatically throw into the nearest boundary, which covers most async use cases.

Do error boundaries catch async errors like failed fetch calls?

No. Error boundaries only catch synchronous render-time exceptions. For async errors in effects or event handlers, use try/catch and manage error state locally or show a toast. Use useErrorBoundary's showBoundary if you want to route an async error into a boundary deliberately.

What's the difference between fallback and fallbackRender in react-error-boundary?

fallback accepts a static React element — it has no access to the error or reset function. fallbackRender accepts a function that receives { error, resetErrorBoundary }, giving you a dynamic fallback. Use fallbackRender whenever you need a retry button or want to display the error message.

Does Next.js error.tsx replace manual error boundaries?

It replaces them at the route-segment level — Next.js wraps each error.tsx as a client-side error boundary automatically. But for sub-page regions like widgets and sidebars, you still need to place boundaries yourself either with the class pattern or react-error-boundary.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

React Error Boundaries: Catching Crashes Without Losing Your MindReact Suspense Patterns: Loading, Error Boundaries and Nested FallbacksTailwind vs CSS Modules in 2026: Which One Should You Actually Use?Figma to React: The Workflow That Actually Saves Time