Himanshu SharmaSoftware engineer
Let’s talk

React · Lesson 33 of 38

Error boundaries, Suspense and lazy loading

Stop one broken component crashing the page with error boundaries, show loading fallbacks with Suspense and load code on demand with React.lazy.

20 minBuilding an appUpdated 24 October 2026

By the end of this lessonCatch rendering errors with a boundary and a retry button, wrap slow parts in Suspense, and load components on demand with lazy.

Two things go wrong in every real app: something breaks, and something is slow. React has built-in answers to both that work for whole sections of the page at once: error boundaries contain breakage, and Suspense handles waiting. This lesson shows both, and how they lead to lazy loading, which makes the first page load smaller.

What happens when a component throws

If a component throws an error while rendering (reading a property of undefined, a bad prop), React cannot show a half-correct page. By default, it removes the entire component tree, and the user sees a blank page. The cause is often one small widget in a corner:

jsx
function Profile({ user }) {
  return <p>Name: {user.name.toUpperCase()}</p>;       // crashes if user.name is missing
}

function App() {
  return (
    <div>
      <h1>My app</h1>
      <Profile user={{ name: "Asha" }} />
      <Profile user={{}} />
    </div>
  );
}

export default App;
result
CONSOLE
warn: An error occurred in the <Profile> component.
Consider adding an error boundary to your tree to customize error handling behavior.
Visit https://react.dev/link/error-boundaries to learn more about error boundaries.
Uncaught TypeError: Cannot read properties of undefined (reading 'toUpperCase')

Neither the heading nor the first profile appears: the whole page is gone because the second Profile threw. The Console panel shows the error. An error boundary limits such damage to the part of the page that broke.

Error boundaries

An error boundary is a component that catches errors thrown by anything below it in the tree, logs them, and shows a fallback UI instead. React still requires boundaries to be written as class components, using two special methods. You write one once and reuse it:

jsx
import { Component, useState } from "react";

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

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

  componentDidCatch(error, info) {
    console.log("logged:", error.message);     // send to a logging service in a real app
  }

  render() {
    if (this.state.error) {
      return (
        <div role="alert">
          <p>Something went wrong in this part of the page.</p>
          <button onClick={() => this.setState({ error: null })}>Try again</button>
        </div>
      );
    }
    return this.props.children;
  }
}

function Profile({ user }) {
  return <p>Name: {user.name.toUpperCase()}</p>;
}

function App() {
  return (
    <div>
      <h1>My app</h1>
      <Profile user={{ name: "Asha" }} />
      <ErrorBoundary>
        <Profile user={{}} />
      </ErrorBoundary>
      <p>The rest of the page still works.</p>
    </div>
  );
}

export default App;
result
My app
Name: ASHA
Something went wrong in this part of the page.
Try again
The rest of the page still works.
CONSOLE
error: TypeError: Cannot read properties of undefined (reading 'toUpperCase')
The above error occurred in the <Profile> component.
React will try to recreate this component tree from scratch using the error boundary you provided, ErrorBoundary.
logged: Cannot read properties of undefined (reading 'toUpperCase')
error: TypeError: Cannot read properties of undefined (reading 'toUpperCase')
The above error occurred in the <Profile> component.
React will try to recreate this component tree from scratch using the error boundary you provided, ErrorBoundary.
logged: Cannot read properties of undefined (reading 'toUpperCase')

Now the heading, the first profile and the closing line all remain; only the broken area shows the message. (The Console also contains React's own error report, because in development React logs every error it catches.) The two methods:

  • getDerivedStateFromError(error) runs while rendering, and returns the state that makes the boundary show the fallback.
  • componentDidCatch(error, info) runs afterwards, for side effects such as reporting the error to a service (Sentry and similar tools). info.componentStack shows which components were involved.

Place boundaries deliberately: one around the whole app (so the user never sees a blank screen), and extra ones around independent sections such as a sidebar, a chart or each route, so one failure does not take down the rest. A "Try again" button resets the boundary's state, which re-renders the children; it helps if the error was temporary or the data changes.

What boundaries do not catch

They catch errors from rendering, lifecycle methods and constructors of the components below them. They do not catch:

  • errors in event handlers (use try/catch and show an error state),
  • errors in asynchronous code such as setTimeout and promise callbacks (catch them and put them in state),
  • errors in effects' callbacks (the same),
  • errors in server rendering,
  • errors thrown in the boundary itself.

If you have a failing fetch, catch it and set an error status, as in the data-fetching lesson. A trick for surfacing an async error in a boundary: set it in state and throw during the next render (if (error) throw error;).

The react-error-boundary package

Because writing the class is clumsy, most projects install react-error-boundary, which provides a ready-made component with a function-style API: <ErrorBoundary fallback={<p>Oops</p>} or FallbackComponent, a resetKeys prop that resets the boundary when some value changes, and an onError callback. The behaviour is the same as above.

React 19 error reporting

React 19 also lets you pass options to createRoot such as onCaughtError and onUncaughtError, central hooks to log every error that boundaries catch or that go unhandled. Use them to feed your monitoring tool in one place.

Suspense: a fallback while waiting

Some components are not ready to render yet because they are waiting for code (a lazily loaded component) or data (a Suspense-aware data source, such as use(promise) from the actions lesson). Suspense declares what to show in the meantime:

jsx
<Suspense fallback={<p>Loading...</p>}>
  <Comments />
</Suspense>

While Comments is waiting, React shows the fallback; when it is ready, React swaps it in. The component itself needs no loading flags: it can be written as if the data were there. You can nest boundaries: a page-level Suspense shows a skeleton for the whole page, and inner ones let parts appear as soon as they are ready. The closer the boundary is to the slow component, the smaller the area that shows a loader.

Lazy loading with React.lazy

A big app ships a lot of JavaScript. Users of the home page do not need the code of the admin page. Code splitting puts rarely used parts in separate files that download when needed. React.lazy does this for components: you give it a function that calls the dynamic import(), which the bundler (Vite) turns into a separate file:

jsx
import { lazy, Suspense } from "react";

const Settings = lazy(() => import("./Settings.jsx"));   // Settings.jsx becomes its own file

function App() {
  return (
    <Suspense fallback={<p>Loading settings...</p>}>
      <Settings />
    </Suspense>
  );
}

The file Settings.jsx must have a default export. The first time <Settings /> renders, the browser downloads its file, lazy suspends in the meantime, and Suspense shows the fallback. Later renders use the already-loaded code. The live editor has only one file, so this example fakes the delay with a promise; the logic is identical:

jsx
import { lazy, Suspense, useState } from "react";

function Heavy() {
  return <p>The heavy component has loaded.</p>;
}

// In a project: lazy(() => import("./Heavy.jsx"))
const LazyHeavy = lazy(() => new Promise(resolve => setTimeout(() => resolve({ default: Heavy }), 300)));

function App() {
  const [show, setShow] = useState(false);
  return (
    <div>
      <button onClick={() => setShow(true)}>Show heavy part</button>
      {show && (
        <Suspense fallback={<p>Loading...</p>}>
          <LazyHeavy />
        </Suspense>
      )}
    </div>
  );
}

export default App;
result
Show heavy part
The heavy component has loaded.

After the click the fallback appears for 300 ms, then the component. In a Vite project, npm run build creates a separate small file for each lazy component (in the dist folder, next to the main bundle), and its name appears in the build output.

Where to split

The best split points are routes (each page is a lazy component, loaded when the user navigates there), big widgets that are rarely used (a rich-text editor, a chart library, a map), and modals that open on click. Do not split tiny components: each file costs a request and a little overhead. A router setup looks like this:

jsx
const Home = lazy(() => import("./pages/Home.jsx"));
const Admin = lazy(() => import("./pages/Admin.jsx"));

<Suspense fallback={<p>Loading page...</p>}>
  <Routes>
    <Route path="/" element={<Home />} />
    <Route path="/admin" element={<Admin />} />
  </Routes>
</Suspense>

Boundaries together

Loading can fail (the network drops while downloading a chunk), so combine them: Suspense for waiting, an error boundary for failure.

jsx
<ErrorBoundary fallback={<p>Could not load this section.</p>}>
  <Suspense fallback={<p>Loading...</p>}>
    <Reports />
  </Suspense>
</ErrorBoundary>

Read it from the outside in: if anything below fails, show the error message; otherwise, while something below waits, show the loading text. This pairing around each independent section gives a UI that degrades gracefully instead of breaking.

Common mistakes

  • Having no error boundary at all, so one small bug produces a blank page.
  • Expecting a boundary to catch errors in click handlers or in fetch callbacks.
  • A single boundary at the top only, so any error replaces the whole page.
  • Forgetting a default export in a file loaded with lazy.
  • Using lazy without a Suspense boundary above it (React throws).
  • Defining the lazy(...) call inside a component, which creates a new lazy component on every render and reloads it; define it at the top level of the module.
  • Splitting every component, creating dozens of tiny requests.
  • Swallowing the error: always log it somewhere you will see.

Exercise

  1. Wrap each of three widgets (weather, news, tasks) in its own ErrorBoundary, make one throw, and check that the other two still work.
  2. Add a "Report problem" button to the fallback that logs the error message.
  3. Lazy-load a Chart component only when the user presses "Show chart", and show a Skeleton as the fallback.
  4. Make the retry button of the boundary also reset when a userId prop changes (hint: pass key={userId} to the boundary, or use resetKeys in react-error-boundary).

Key points

  • An error thrown while rendering unmounts the whole app unless an error boundary above it catches the error and shows a fallback.
  • Error boundaries must be class components (or come from the react-error-boundary package); they do not catch errors in event handlers, async code or effects.
  • Suspense shows a fallback while a child waits for code (lazy) or data (use, or a Suspense-aware library).
  • React.lazy(() => import('./Page.jsx')) splits a component into its own file that downloads only when it is first rendered.

Check yourself

1. What happens by default when a component throws an error during rendering?
2. Which errors does an error boundary NOT catch?
3. What does the fallback prop of Suspense show?
4. What is the benefit of React.lazy?