EthicsComputerCourses
Join training +91 90532 10052

Error Boundaries & Error Handling

React · Lesson 25 of 41 Advanced 8 min read Updated

Every real app meets errors: an API returns data in an unexpected shape, a library has a bug, a user's browser blocks storage. What matters is what the user sees next. Without any planning, a single error thrown while rendering removes your whole React app from the page and leaves a blank white screen.

You met error boundaries briefly in Class Components (Legacy) and Suspense, lazy & use(). In this lesson you will build a reusable boundary with a "Try again" button, decide where boundaries belong, handle errors that boundaries cannot catch, use the react-error-boundary package and route error pages, and report errors from production with createRoot options.

What happens without a boundary

If a component throws while React is rendering it and no error boundary is above it, React unmounts the entire root. The thinking is that showing a broken, half-updated screen (for example a wrong bank balance) is worse than showing nothing. React then reports the error. You can see both in this example, which also uses the onUncaughtError option of createRoot to log what happened:

React JSXTry it yourself
import { useState } from "react";
import { createRoot } from "react-dom/client";

function Scorecard() {
  const [match, setMatch] = useState({ team: "Rajasthan", runs: 182, overs: 20 });
  return (
    <div>
      <p>{match.team}: {match.runs}/{match.overs} overs, run rate {(match.runs / match.overs).toFixed(2)}</p>
      <button onClick={() => setMatch(null)}>Load next match (buggy)</button>
    </div>
  );
}

const root = createRoot(document.getElementById("root"), {
  onUncaughtError(error) {
    console.log(`Uncaught: ${error.message}`);
  },
});

root.render(
  <main>
    <h2>Live scores</h2>
    <Scorecard />
    <p>Footer: scores update every 30 seconds</p>
  </main>
);

Clicking the buggy button sets match to null, so reading match.team throws. The heading, the scorecard and the footer all disappear, and the console shows:

Output
Uncaught: Cannot read properties of null (reading 'team')

A reusable boundary with a retry button

An error boundary is a component that catches errors thrown while rendering anything inside it and shows a fallback instead. React still only supports boundaries as class components, so teams write one small class (or install a package) and reuse it everywhere. This version accepts a fallback function, so each place can choose its own message, and gives that function a reset callback for a "Try again" button:

React JSXTry it yourself
import { Component, useState } from "react";
import { createRoot } from "react-dom/client";

class ErrorBoundary extends Component {
  state = { error: null };

  static getDerivedStateFromError(error) {
    return { error }; // show the fallback on the next render
  }

  componentDidCatch(error) {
    console.log(`Reported "${error.message}" from ${this.props.name}`);
  }

  reset = () => {
    this.props.onReset?.(); // let the parent fix or refetch the data first
    this.setState({ error: null });
  };

  render() {
    if (this.state.error) {
      return this.props.fallback({ error: this.state.error, reset: this.reset });
    }
    return this.props.children;
  }
}

function WeatherWidget({ data }) {
  return <p>{data.city.toUpperCase()}: {data.temp}°C</p>;
}

function NewsWidget() {
  return <p>Metro Phase 2 opens next month</p>;
}

function retryFallback({ error, reset }) {
  return (
    <div role="alert" style={{ color: "#b91c1c" }}>
      This section failed to load ({error.message}). <button onClick={reset}>Try again</button>
    </div>
  );
}

function Dashboard() {
  const [weather, setWeather] = useState({}); // the first API answer was incomplete
  const box = { border: "1px solid #ccc", padding: 8, marginBottom: 8 };
  return (
    <>
      <h2>City dashboard</h2>
      <section style={box}>
        <ErrorBoundary name="WeatherWidget" fallback={retryFallback}
          onReset={() => setWeather({ city: "Kochi", temp: 29 })}>
          <WeatherWidget data={weather} />
        </ErrorBoundary>
      </section>
      <section style={box}>
        <ErrorBoundary name="NewsWidget" fallback={retryFallback}>
          <NewsWidget />
        </ErrorBoundary>
      </section>
    </>
  );
}

createRoot(document.getElementById("root")).render(<Dashboard />);

On load, the weather widget fails, but only its box shows the red message; the heading and the news stay. Besides React's own report of the caught error (a red entry with a stack trace), the console prints:

Output
Reported "Cannot read properties of undefined (reading 'toUpperCase')" from WeatherWidget

Click "Try again". reset first calls onReset, which in a real app would refetch the data (here it sets complete data), then clears the stored error. React renders the children again and "KOCHI: 29°C" appears. Retrying without fixing the cause would only fail again, because React components must be pure: the same data gives the same result.

Where should boundaries go? Think about what is safe to lose:

  • One around the whole app, with a friendly "Something went wrong, reload the page" screen, so users never see a blank page.
  • One per route or page, so a broken page keeps the navigation bar usable.
  • One around each independent widget on dashboards and feeds, like the example above.

Do not wrap every small component. A boundary is useful where the rest of the screen still makes sense without the broken part.

What boundaries cannot catch

Boundaries only catch errors thrown during rendering (and in lifecycle methods and effects of the components inside). They do not catch errors in event handlers, in setTimeout callbacks or in promises that your code starts, because React is not rendering when those run. Handle such errors with try...catch and state. If the error should show the boundary's fallback, store it in state and throw it during the next render:

React JSX
function PayButton({ amount }) {
  const [error, setError] = useState(null);
  const [notice, setNotice] = useState("");
  if (error) throw error; // now the nearest boundary handles it

  async function handlePay() {
    try {
      await payWithUpi(amount);
    } catch (err) {
      if (err.code === "DECLINED") setNotice("Payment declined. Try another UPI app."); // expected: show it here
      else setError(err); // unexpected: let the boundary show its fallback
    }
  }
  return (
    <>
      <button onClick={handlePay}>Pay ₹{amount}</button>
      {notice && <p>{notice}</p>}
    </>
  );
}

Expected problems, such as a declined payment or a wrong password, should be shown right where they happen, as normal UI. Only unexpected failures belong in a boundary. Two cases are handled for you: an error thrown inside a function passed to startTransition (from useTransition) and an error thrown by a form action both go to the nearest boundary automatically.

react-error-boundary and route errors

Most projects use the small react-error-boundary package instead of their own class. It adds resetKeys, which reset the boundary automatically when a value changes, and a hook for sending caught errors to the boundary:

React JSX
import { ErrorBoundary, useErrorBoundary } from "react-error-boundary";

function OrdersPage({ userId }) {
  return (
    <ErrorBoundary
      fallbackRender={({ error, resetErrorBoundary }) => (
        <div role="alert">
          Could not show orders. <button onClick={resetErrorBoundary}>Try again</button>
        </div>
      )}
      onError={(error, info) => logError(error, info.componentStack)}
      resetKeys={[userId]} // a different user resets the boundary
    >
      <OrderList userId={userId} />
    </ErrorBoundary>
  );
}

function ExportButton() {
  const { showBoundary } = useErrorBoundary();
  return <button onClick={() => exportOrders().catch(showBoundary)}>Export CSV</button>;
}

If you use React Router in data mode, each route can also have an ErrorBoundary component. It shows when that route's loader, action or component throws, and it reads the error with useRouteError(); isRouteErrorResponse(error) tells you whether it was a thrown response such as a 404:

React JSX
const router = createBrowserRouter([
  { path: "/orders/:id", loader: orderLoader, Component: OrderPage, ErrorBoundary: OrderError },
]);

function OrderError() {
  const error = useRouteError();
  if (isRouteErrorResponse(error)) return <h2>{error.status}: order not found</h2>;
  return <h2>Something went wrong while loading this order.</h2>;
}

Reporting errors from production

Users rarely report errors; they simply leave. Production apps send every error to a monitoring service (Sentry, Datadog, your own API) together with the component stack, which lists the components the error passed through. React 19 lets you hook into this for the whole app with three createRoot options:

React JSX
const root = createRoot(document.getElementById("root"), {
  onCaughtError(error, errorInfo) {
    // caught by an error boundary: the user saw a fallback
    sendToMonitoring({ level: "error", message: error.message, stack: errorInfo.componentStack });
  },
  onUncaughtError(error, errorInfo) {
    // no boundary caught it: the app was removed from the page
    sendToMonitoring({ level: "fatal", message: error.message, stack: errorInfo.componentStack });
  },
  onRecoverableError(error) {
    // React recovered by itself, for example after a hydration mismatch
    sendToMonitoring({ level: "warning", message: error.message });
  },
});

By default React prints these errors to the console. Component names in production stacks are minified, so monitoring tools use your build's source maps to show real names and line numbers.

Common mistakes

  • No boundary at all. One bad API response blanks the whole app. Add at least a top-level boundary.
  • Expecting boundaries to catch event handler errors. Use try...catch in handlers and async code.
  • Throwing for expected problems. A wrong coupon or failed login is normal UI, not a crash.
  • Retrying without fixing the cause. A "Try again" button should also refetch or reset the data that failed.
  • Logging only to the console. In production nobody reads it; report errors to a service.

Key points

  • An uncaught render error unmounts the whole React root and shows a blank page.
  • Error boundaries catch render errors in their children and show a fallback; they must be classes, or come from a package.
  • Place boundaries around the app, each route, and independent widgets; give fallbacks a retry option.
  • Event handlers and async code need try...catch; store an unexpected error in state and throw it to reach a boundary.
  • react-error-boundary adds fallbackRender, resetKeys and useErrorBoundary; React Router routes have their own ErrorBoundary.
  • createRoot options onCaughtError, onUncaughtError and onRecoverableError send errors to monitoring.

Exercise

A train enquiry page shows a TrainStatus component that throws during render if the train number is not five digits. Wrap it in your own ErrorBoundary class (from this lesson) with a fallback that says "Invalid train number" and a "Try again" button. Add an input and a "Check" button outside the boundary that sets the train number. Make the boundary reset automatically whenever the train number changes, by giving the boundary key={trainNo}.

Show solution
React JSX
import { Component, useState } from "react";
import { createRoot } from "react-dom/client";

class ErrorBoundary extends Component {
  state = { error: null };
  static getDerivedStateFromError(error) {
    return { error };
  }
  render() {
    if (this.state.error) {
      return this.props.fallback({ error: this.state.error, reset: () => this.setState({ error: null }) });
    }
    return this.props.children;
  }
}

function TrainStatus({ trainNo }) {
  if (!/^\d{5}$/.test(trainNo)) throw new Error(`"${trainNo}" is not a 5-digit train number`);
  return <p>Train {trainNo} is running 10 minutes late.</p>;
}

function Enquiry() {
  const [draft, setDraft] = useState("12015");
  const [trainNo, setTrainNo] = useState("12015");
  return (
    <div>
      <input value={draft} onChange={(e) => setDraft(e.target.value)} aria-label="Train number" />
      <button onClick={() => setTrainNo(draft.trim())}>Check</button>
      <ErrorBoundary
        key={trainNo}
        fallback={({ error, reset }) => (
          <p role="alert">
            Invalid train number: {error.message} <button onClick={reset}>Try again</button>
          </p>
        )}
      >
        <TrainStatus trainNo={trainNo} />
      </ErrorBoundary>
    </div>
  );
}

createRoot(document.getElementById("root")).render(<Enquiry />);

A new key makes React create a fresh boundary whenever the train number changes, which resets its error state. That is exactly what resetKeys does in react-error-boundary.

Quick Quiz

1. A component throws while rendering and there is no error boundary. What happens in React 19?

2. Which error is NOT caught by an error boundary?

3. Why give a fallback a "Try again" button that calls reset?

4. Which createRoot option runs when an error boundary catches an error?

Learn React with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call