Error Boundaries and Suspense in React

React & Next.js: The Complete Developer HandbookLesson 14 of 54

Part 1: React Foundations · 14 of 16

Goal: contain rendering errors with error boundaries, show loading states with Suspense, and read a promise with React 19’s use, so that one slow or broken part of a page never blanks out the rest.

A tool page in the library is made of independent parts: the tool’s name and Reserve button, a details panel, member reviews, the next free date. Some of them depend on data that arrives late or not at all. Without a plan, one failing part takes the whole page with it, and one slow part keeps everything waiting. This lesson gives each part its own safety net (an error boundary) and its own loading placeholder (a Suspense boundary).

What happens when a component throws

Suppose the reviews service returns data your code did not expect, and the Reviews component throws while rendering. Run this, then click Show reviews:

import { useState } from 'react';

function Reviews() {
  throw new Error('Reviews service returned bad data');
}

function App() {
  const [showReviews, setShowReviews] = useState(false);

  return (
    <div>
      <h2>Cordless drill</h2>
      <button onClick={() => setShowReviews(true)}>Show reviews</button>
      {showReviews && <Reviews />}
    </div>
  );
}

The whole result goes blank: the tool name and the button disappear too, not just the reviews. The console shows the error and suggests adding an error boundary.

That is deliberate. When a component throws during rendering, React cannot know what the screen is supposed to look like, and showing a half-updated interface could be worse than showing nothing (imagine a basket that displays the wrong tools). So, unless you say otherwise, React removes the entire tree it rendered. An error boundary is how you say otherwise: “if anything inside here fails to render, show this instead, and keep the rest of the page.”

Writing an error boundary

An error boundary is a component that catches errors thrown while rendering any component below it. In React 19 there is still no Hook for this: the boundary itself must be a class component with a static method called getDerivedStateFromError. You only write it once; every other component in your app stays a function.

import { Component, useState } from 'react';

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

  static getDerivedStateFromError(error) {
    return { error };
  }

  componentDidCatch(error, info) {
    console.log('Send to error tracking:', error.message);
  }

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

function Reviews() {
  throw new Error('Reviews service returned bad data');
}

function App() {
  const [showReviews, setShowReviews] = useState(false);

  return (
    <div>
      <h2>Cordless drill</h2>
      <button onClick={() => setShowReviews(true)}>Show reviews</button>
      <ErrorBoundary fallback={<p>Reviews are unavailable right now.</p>}>
        {showReviews && <Reviews />}
      </ErrorBoundary>
    </div>
  );
}

Click Show reviews again: this time the heading and button stay, and only the reviews area shows “Reviews are unavailable right now.” The pieces:

  • getDerivedStateFromError(error) runs when a child throws during rendering. It returns the boundary’s new state, here { error }, so the next render shows the fallback. It must be pure: no logging or network calls inside it.
  • componentDidCatch(error, info) runs after the fallback is on screen. It is the place for side effects such as reporting the error to a monitoring service (Lesson 51). info.componentStack tells you which components the error passed through.
  • render() shows the fallback while there is an error and the children otherwise.

Expect console output in development. Even though the boundary handled the error, React’s development build still logs it (“The above error occurred in the <Reviews> component…”) so that you notice it. The production build does not log caught errors itself, which is why reporting belongs in componentDidCatch. (When you create the root yourself, createRoot also accepts onCaughtError and onUncaughtError options for central reporting.)

If you would rather not maintain the class, the small react-error-boundary package provides a ready-made ErrorBoundary component with fallbacks and reset support. The concepts in this lesson apply to it unchanged.

What error boundaries do not catch

An error boundary catches errors thrown while React is rendering (and in lifecycle methods and effects) of the components inside it. It does not see:

  • Errors in event handlers. A click handler runs after rendering has finished, so React is not in the middle of drawing anything. The screen is still valid, and React leaves the error to you.
  • Errors in asynchronous code you start yourself, such as a setTimeout callback or the .then of a promise you did not hand to React.
  • Errors in the boundary itself. A boundary protects its children, not its own render. If the fallback throws, the error goes to the next boundary up.
  • Errors during server rendering. In Next.js the framework has its own error files (Lesson 31).

So the “Report damage” button for the hedge trimmer needs ordinary JavaScript error handling. Click it and wait a moment:

import { useState } from 'react';

function sendDamageReport(toolId) {
  // Pretend server: this request always fails.
  return new Promise((resolve, reject) => {
    setTimeout(() => reject(new Error('Report service is offline')), 600);
  });
}

function DamageReport() {
  const [message, setMessage] = useState('');

  async function handleClick() {
    setMessage('Sending…');
    try {
      await sendDamageReport('t4');
      setMessage('Thanks, a volunteer will check the hedge trimmer.');
    } catch (error) {
      setMessage(`Could not send the report (${error.message}). Please try again.`);
    }
  }

  return (
    <div>
      <h2>Hedge trimmer</h2>
      <button onClick={handleClick}>Report damage</button>
      <p role="status">{message}</p>
    </div>
  );
}

It shows “Sending…”, then “Could not send the report (Report service is offline). Please try again.” The try/catch turns the failure into state, and state is something React knows how to render. The role="status" makes screen readers announce the message when it changes. Remove the try/catch and the error ends up in the console as an unhandled promise rejection, with nothing on screen to tell the member.

There is one exception worth knowing now: errors thrown inside a function passed to startTransition (Lesson 15) are passed to the nearest error boundary. And react-error-boundary offers a useErrorBoundary Hook whose showBoundary(error) lets an event handler hand an error to a boundary on purpose.

Where to place boundaries, and how to recover

A boundary’s position decides how much of the page a failure costs. Think about which parts can fail independently and still leave a useful page:

  • Around independent widgets: reviews, the availability panel, a “similar tools” strip. If reviews fail, the member can still reserve the drill.
  • Around a whole page or route: a last line of defence with a friendly message and a link home, so a bug never shows a blank white screen.
  • Not around every component. Wrapping each card in its own boundary adds noise without helping anyone. If the fallback for a part would be “the page is useless anyway”, that part does not need its own boundary.

Once a boundary shows its fallback, it stays that way until its state is reset. Two common ways to reset it:

  • Give it a key that changes when the content should start fresh. <ErrorBoundary key={tool.id}> gives each tool a brand-new boundary, so a failure on the drill’s page does not stick when the member moves to the sander. (Changing a key makes React discard the old component and its state.)
  • Offer a Try again button in the fallback that clears the error. That only helps if something has changed: re-rendering the same broken data simply throws again. The next section shows a retry that does real work.
<ErrorBoundary key={tool.id} fallback={<p>Reviews are unavailable right now.</p>}>
  <Reviews toolId={tool.id} />
</ErrorBoundary>

Suspense and lazy loading

Errors are one kind of “not ready”; slowness is the other. Suspense lets a component say “I can’t render yet”, and lets a parent decide what to show in the meantime:

<Suspense fallback={<p>Loading details…</p>}>
  <ToolDetails />
</Suspense>

While anything inside is waiting, React shows the fallback. When everything inside is ready, it swaps in the real content. Suspense does not do any loading itself; it only reacts to children that *suspend*. In plain React, two things suspend: a component created with lazy whose code has not arrived yet, and a component reading an unfinished promise with use (next section). Frameworks such as Next.js add more (Lesson 30).

lazy splits a component into its own JavaScript file that is only downloaded the first time it renders. The tool details panel, with its long description and safety notes, is a good candidate: most visitors never open it. In a real project you write const ToolDetails = lazy(() => import('./ToolDetails.js')); at the top level of a module, and the bundler creates the separate file. The live editor cannot load files, so this example fakes the import with a one-second delay. lazy only needs a function that returns a promise of an object whose default is a component:

import { lazy, Suspense, useState } from 'react';

// Real project: const ToolDetails = lazy(() => import('./ToolDetails.js'));
const ToolDetails = lazy(() => new Promise((resolve) => {
  setTimeout(() => resolve({ default: ToolDetailsPanel }), 1000);
}));

function ToolDetailsPanel() {
  return (
    <dl>
      <dt>Category</dt><dd>Power tools</dd>
      <dt>Longest loan</dt><dd>7 days</dd>
      <dt>Safety</dt><dd>Wear eye protection. Charge the battery before returning.</dd>
    </dl>
  );
}

function App() {
  const [open, setOpen] = useState(false);

  return (
    <div>
      <h2>Cordless drill</h2>
      <button onClick={() => setOpen(!open)}>{open ? 'Hide details' : 'Show details'}</button>
      {open && (
        <Suspense fallback={<p>Loading details…</p>}>
          <ToolDetails />
        </Suspense>
      )}
    </div>
  );
}

Click Show details: “Loading details…” appears for about a second, then the details. Hide them and show them again: they appear at once, because lazy caches the loaded component and never calls the loading function a second time.

Two rules make lazy work properly. Declare lazy components at the top level of a module, never inside another component, or every render creates a new component type and throws its state away. And place the Suspense boundary where a loading placeholder makes sense to a member. Without any Suspense boundary above it, the waiting component holds back the whole tree: the page stays blank until the code arrives.

Reading a promise with use

React 19 adds use, which reads the value of a promise during rendering. If the promise is still pending, the component suspends and the nearest Suspense fallback shows. When it resolves, React renders the component again and use returns the value. The extension ladder is on loan; a member wants to know when it is free:

import { Suspense, use, useState } from 'react';

function fetchAvailability(toolId) {
  return new Promise((resolve) => {
    setTimeout(() => resolve({ toolId, nextFreeDate: '2026-12-01' }), 600);
  });
}

function NextFreeDate({ availabilityPromise }) {
  const availability = use(availabilityPromise);
  return <p>Next free date: {availability.nextFreeDate}</p>;
}

function App() {
  const [availabilityPromise, setAvailabilityPromise] = useState(null);

  return (
    <div>
      <h2>Extension ladder</h2>
      <p>Status: On loan</p>
      <button onClick={() => setAvailabilityPromise(fetchAvailability('t2'))}>
        Check availability
      </button>
      {availabilityPromise && (
        <Suspense fallback={<p>Checking availability…</p>}>
          <NextFreeDate availabilityPromise={availabilityPromise} />
        </Suspense>
      )}
    </div>
  );
}

Click Check availability: “Checking availability…” shows briefly, then “Next free date: 2026-12-01”. Compare this with the effect-based loading in Lesson 9: there is no loading state, no effect and no ignore flag. NextFreeDate is written as if the data were already there, and the boundary above it handles the waiting.

The important detail is where the promise is created: in the click handler, then kept in state. The promise must stay the same object from one render to the next. This tempting version never finishes loading:

function NextFreeDate({ toolId }) {
  // Wrong: a brand-new promise on every render.
  const availability = use(fetchAvailability(toolId));
  return <p>Next free date: {availability.nextFreeDate}</p>;
}

Each render starts a new request and suspends on it. When it resolves, React renders again, which starts another request, and the fallback stays forever. Create promises in event handlers, in a cache, or (the common case in Next.js) in a Server Component that passes them down as props (Lessons 27 and 30). Two more facts about use: unlike Hooks it may be called inside an if, and you cannot wrap it in try/catch to handle a rejected promise. A rejection goes to the nearest error boundary instead, which brings the two halves of this lesson together.

Loading and failure together

Reviews come from a separate service that is sometimes slow and sometimes down. Nest a Suspense boundary for “slow” inside an error boundary for “down”. In this example the pretend server fails the first request and answers the later ones:

import { Component, Suspense, use, useState } from 'react';

class ErrorBoundary extends Component {
  state = { error: null };
  static getDerivedStateFromError(error) {
    return { error };
  }
  render() {
    return this.state.error ? this.props.renderFallback(this.state.error) : this.props.children;
  }
}

let requests = 0;
function fetchReviews(toolId) {
  requests += 1;
  const failThisTime = requests === 1; // Pretend server: only the first request fails.
  return new Promise((resolve, reject) => {
    setTimeout(() => failThisTime
      ? reject(new Error('Reviews service timed out'))
      : resolve([{ id: 'r1', text: 'Light, and the battery lasts all afternoon.' }]), 600);
  });
}

function ReviewList({ reviewsPromise }) {
  const reviews = use(reviewsPromise);
  return <ul>{reviews.map((review) => <li key={review.id}>{review.text}</li>)}</ul>;
}

function App() {
  const [reviewsPromise, setReviewsPromise] = useState(null);
  const [attempt, setAttempt] = useState(0);

  function load() {
    setReviewsPromise(fetchReviews('t1'));
    setAttempt((n) => n + 1);
  }

  return (
    <section>
      <h2>Cordless drill: reviews</h2>
      {!reviewsPromise && <button onClick={load}>Load reviews</button>}
      {reviewsPromise && (
        <ErrorBoundary key={attempt} renderFallback={(error) => (
          <div role="alert">
            <p>Could not load reviews: {error.message}</p>
            <button onClick={load}>Try again</button>
          </div>
        )}>
          <Suspense fallback={<p>Loading reviews…</p>}>
            <ReviewList reviewsPromise={reviewsPromise} />
          </Suspense>
        </ErrorBoundary>
      )}
    </section>
  );
}

Click Load reviews: “Loading reviews…”, then “Could not load reviews: Reviews service timed out” with a Try again button (and React’s development log of the caught error in the console). Click Try again: loading again, then the review. The retry does two things, and both are needed:

  1. It creates a new promise. A promise that has rejected stays rejected, so reading the old one again would fail again.
  2. It changes the boundary’s key, so React replaces the boundary with a fresh one whose error is back to null. Without that, the boundary would keep showing its fallback whatever the new promise did.

This boundary takes renderFallback, a function, instead of a ready-made element, so the fallback can show the error message. Show members a message they can act on; keep stack traces for your error tracking.

Common mistakes

  • Expecting a boundary to catch a click handler’s error. The button sits inside a boundary, the handler throws, and nothing changes on screen. In review, look for await calls in handlers without a try/catch (or a .catch) and no error state.
  • One boundary at the very top and nowhere else. Every failure, however small, replaces the whole page. Look at the page and ask which parts could fail alone; give those their own boundary.
  • **Creating the promise inside the component that calls use.** The fallback never goes away and the Network panel shows repeated requests. The promise must come from outside the render: an event handler, a cache or a parent that keeps it.
  • A Try again button that changes nothing. It clears the error, the same data throws again, and the member sees the fallback flash. A retry must fetch again (new promise) and reset the boundary (new key or reset method).
  • Using Suspense for every loading state. A Reserve button that is “sending” is not suspended rendering; it is a pending action with its own feedback (Lesson 15). Suspense is for parts of the page whose code or data is not ready to render.

Build it: a resilient tool page

The starter below renders a tool page with a lazily loaded details panel and a reviews widget. The reviews data for this tool is missing, so Reviews throws when it tries to map over undefined. Run it: the result stays blank and the console shows a TypeError from Reviews. There are two problems: nothing tells React what to show while the details load, and nothing contains the reviews error. Change App so that:

  • “Loading details…” shows while the details load, while the heading and the Reserve button are visible straight away.
  • The reviews area shows “Reviews are unavailable right now.” and the rest of the page keeps working.
  • The ErrorBoundary class is provided; do not change it, and do not fix Reviews itself (the point is to survive bad data you have not foreseen).
import { Component, lazy, Suspense } from 'react';

class ErrorBoundary extends Component {
  state = { error: null };
  static getDerivedStateFromError(error) {
    return { error };
  }
  render() {
    return this.state.error ? this.props.fallback : this.props.children;
  }
}

const ToolDetails = lazy(() => new Promise((resolve) => {
  setTimeout(() => resolve({ default: () => <p>Category: Power tools. Longest loan: 7 days.</p> }), 1000);
}));

const tool = { id: 't1', name: 'Cordless drill' }; // No reviews field: bad data.

function Reviews({ tool }) {
  return <ul>{tool.reviews.map((review) => <li key={review.id}>{review.text}</li>)}</ul>;
}

function App() {
  return (
    <div>
      <h2>{tool.name}</h2>
      <button>Reserve</button>
      <ToolDetails />
      <Reviews tool={tool} />
    </div>
  );
}

Hint: you need two different boundaries, one per problem. Which one handles “not ready yet” and which one handles “broke while rendering”? Each should wrap only the component it protects.

Show a sample solution
import { Component, lazy, Suspense } from 'react';

class ErrorBoundary extends Component {
  state = { error: null };
  static getDerivedStateFromError(error) {
    return { error };
  }
  render() {
    return this.state.error ? this.props.fallback : this.props.children;
  }
}

const ToolDetails = lazy(() => new Promise((resolve) => {
  setTimeout(() => resolve({ default: () => <p>Category: Power tools. Longest loan: 7 days.</p> }), 1000);
}));

const tool = { id: 't1', name: 'Cordless drill' }; // No reviews field: bad data.

function Reviews({ tool }) {
  return <ul>{tool.reviews.map((review) => <li key={review.id}>{review.text}</li>)}</ul>;
}

function App() {
  return (
    <div>
      <h2>{tool.name}</h2>
      <button>Reserve</button>
      <Suspense fallback={<p>Loading details…</p>}>
        <ToolDetails />
      </Suspense>
      <ErrorBoundary key={tool.id} fallback={<p>Reviews are unavailable right now.</p>}>
        <Reviews tool={tool} />
      </ErrorBoundary>
    </div>
  );
}

The heading, the button and “Reviews are unavailable right now.” appear immediately, with “Loading details…” in between until the details arrive a second later. The console still shows React’s development log of the caught TypeError; that is expected. Suspense handles the details because they are not ready yet; the error boundary handles the reviews because they broke. Wrapping both in a single error boundary would hide the details too when the reviews fail, and the key means a different tool would start with a fresh boundary.

Check your understanding

Answer each question in a sentence or two before opening the answer.

1. A Reserve button’s click handler throws, and the error boundary around the tool card does not show its fallback. Why not, and what should you do?

Error boundaries only catch errors thrown while React renders the components inside them. A click handler runs after rendering, when the screen is still valid, so React leaves the error to you. Catch it in the handler with try/catch and put a message in state so the member sees what went wrong; if you really want the boundary’s fallback, use a transition (Lesson 15) or a helper such as showBoundary from react-error-boundary.

2. A component calls use(fetchAvailability(tool.id)) and the page shows “Checking availability…” forever. What is wrong, and how would you confirm it?

The promise is created during render, so every render gets a new, pending promise. Each time one resolves, React renders again, starts another request and suspends again. The Network panel in the browser’s developer tools (or a console.log inside the fetch function) shows the request repeating. Create the promise outside the render, for example in an event handler kept in state or passed in by a parent, so the same promise is read every time.

3. How would you check that the boundary around the reviews widget actually works before a real outage tests it for you?

Make the widget fail on purpose in development: throw an error in Reviews, or feed it data with a missing field as in the exercise. Check that only the reviews area shows the fallback, that the heading and Reserve button still work, that “Try again” (if you have one) really recovers, and that componentDidCatch reports the error. Then capture it in an automated test that renders the page with failing data and expects the fallback text (Lesson 49 covers testing).

Key takeaways

  • Without an error boundary, an error while rendering removes the whole React tree. A boundary limits the damage to the part it wraps.
  • Error boundaries are still class components with getDerivedStateFromError (and componentDidCatch for reporting), or a library such as react-error-boundary.
  • Boundaries do not catch errors in event handlers or your own async code: handle those with try/catch and state.
  • Suspense shows a fallback while children suspend: lazy components loading their code, or components reading a pending promise with use.
  • Promises read with use must be created outside the render. A retry needs a new promise and a reset boundary.

Priority: should know. You should be able to place error and Suspense boundaries on a page, write the boundary class once and recognise errors that boundaries cannot catch. For the exact APIs, see Suspense and catching rendering errors with an error boundary in the React documentation.

Code examples in this course are free to use under the MIT licence. Lesson text © 2026 Farooq Bin Munir.