-- online Search!

Suspense and Error Boundaries

by kirupa   |   filed under Learning React

Every app that does something interesting has two awkward moments. The first is when something isn't ready yet: a chunk of code is still downloading, or the data you need is still on its way. The second is when something goes wrong: the server doesn't answer, or a component throws an error. If you do nothing about either one, your users see a blank spot in the first case and a completely empty page in the second.

React gives us a tool for each situation. Suspense lets you say what to show while part of your UI is waiting. Error boundaries let you say what to show when part of your UI breaks. They are meant to work together, and that's why they share a tutorial.

In this tutorial, we are going to look at both. Along the way, we'll also meet two hooks, useTransition and useDeferredValue, that keep your UI from flashing loading screens at people. Every example you'll see here was run on React 19.3, so what you read is what you'll see in your browser.

Onwards!

OMG! A React Book Written by Kirupa?!!

To kick your React skills up a few notches, everything you see here and more (with all its casual clarity!) is available in both paperback and digital editions.

BUY ON AMAZON

The Big Picture

Both Suspense and error boundaries are components that you wrap around other components. Anything inside the wrapper is covered by it. The following diagram shows how I'll set things up for most of this tutorial:

A diagram of a component tree: an ErrorBoundary contains a Suspense boundary, which contains a Profile component that is waiting on data and a Comments component that throws an error

The Profile component is waiting for its data, so the nearest Suspense above it shows its fallback until the data arrives. The Comments component throws an error, so the nearest ErrorBoundary above it shows its own fallback instead. Each one looks up the tree to find its closest boundary, a bit like how context looks up the tree to find the closest provider.

Let's start with the waiting part.

Meet Suspense

The Suspense component takes a fallback prop. This is whatever you want to show while something inside is not ready:

<Suspense fallback={<p>Loading...</p>}>
  <SomethingThatIsNotReadyYet />
</Suspense>

When a component inside a Suspense boundary isn't ready, React suspends it, shows the fallback, and then swaps in the real content once everything is ready. You don't write any isLoading state or if statement. The component that needs the thing just asks for it, and the boundary above it deals with the waiting.

Right now, two things can make a component suspend: code that is loaded lazily, and data that is read with the use function. We'll look at both.

Suspense with Lazy Loading

The easiest way to see Suspense in action is with code splitting. Normally, when you build your app, all of your components get bundled into one big JavaScript file. That is fine for small apps. For bigger apps, you might have a heavy component (a chart, an editor, a settings screen) that most people never open. Why make everyone download it up front?

React's lazy function lets you hold off on loading a component until the first time it is shown. Let's say we have a Chart component in a file called Chart.jsx:

export default function Chart() {
  return (
    <svg width="120" height="60" role="img" aria-label="A tiny bar chart">
      <rect x="10" y="30" width="20" height="30" fill="#0099FF" />
      <rect x="50" y="10" width="20" height="50" fill="#FF3399" />
      <rect x="90" y="20" width="20" height="40" fill="#669900" />
    </svg>
  );
}

It's not much of a chart, but it will do. Now here is an app that loads it only when you ask for it:

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

const Chart = lazy(() => import("./Chart.jsx"));

export default function App() {
  const [showChart, setShowChart] = useState(false);

  return (
    <>
      <button onClick={() => setShowChart(true)}>Show Chart</button>
      {showChart && (
        <Suspense fallback={<p>Loading chart...</p>}>
          <Chart />
        </Suspense>
      )}
    </>
  );
}

The highlighted lazy line is where the magic is. We hand lazy a function that calls import(), the browser's built-in way of loading a JavaScript file on demand. It returns a promise, and lazy turns that promise into a component we can use like any other. The file you import needs to have its component as the default export, and the lazy call needs to live at the top level of your file, outside of any component. If you put it inside a component, you'd create a brand new lazy component on every render and lose everything each time.

When you click Show Chart, React tries to render Chart for the first time, notices that the code isn't there yet, and shows Loading chart... while the file downloads. When the file arrives, the chart replaces the message.

If you build this app with Vite, you will see that Chart.jsx ends up as its own separate file in the dist/assets folder (mine was called Chart- followed by some random characters, and it was under half a kilobyte). That's the code splitting at work. That file is only downloaded when someone clicks the button.

One thing you may notice: when you run this on your own computer, the fallback flashes by so fast that you can barely see it. That's because your dev server answers instantly. To see it, open your browser's developer tools, go to the Network tab, and switch the throttling option from No throttling to Slow 4G (or whatever the slowest preset is called in your browser). Click the button again and you'll get a good look at the fallback.

Suspense with the use API

Lazy loading is a nice way to meet Suspense, but the more common reason to wait is data. In the What's New in React 19 tutorial, we looked at the use function, which reads the value out of a promise. If the promise hasn't settled yet, use suspends the component, and the closest Suspense boundary shows its fallback.

For the examples in this tutorial, we need some data to wait for. Create a file called data.js with the following code:

const cache = new Map();

async function loadUser(id) {
  // Pretend this is a network request (Sherlock's is slower)
  await new Promise((resolve) => setTimeout(resolve, id === 2 ? 2000 : 1000));
  if (id === 3) {
    throw new Error("User 3 could not be loaded");
  }
  return { id, name: ["Batman", "Sherlock Holmes", "Nobody"][id - 1] };
}

export function fetchUser(id) {
  if (!cache.has(id)) {
    cache.set(id, loadUser(id));
  }
  return cache.get(id);
}

export function forgetUser(id) {
  cache.delete(id);
}

The loadUser function pretends to be a network request by waiting a second (two seconds for Sherlock) and then returning a user. If you ask for user number 3, it fails on purpose. We'll use that later when we get to errors. The part to pay attention to is the highlighted fetchUser function. It keeps every promise it creates in a cache, so asking for the same user twice gives you back the same promise. That detail matters more than it looks. The promise you give to use has to be the same one on every render. If you create a new promise each time the component renders, React will keep waiting on a new promise forever.

Now let's use it:

import { Suspense, use, useState } from "react";
import { fetchUser } from "./data.js";

function Profile({ id }) {
  const user = use(fetchUser(id));
  return <h2>{user.name}</h2>;
}

export default function App() {
  const [id, setId] = useState(1);

  return (
    <>
      <button onClick={() => setId(1)}>Batman</button>
      <button onClick={() => setId(2)}>Sherlock</button>
      <Suspense fallback={<p>Loading profile...</p>}>
        <Profile id={id} />
      </Suspense>
    </>
  );
}

When this app first appears, Profile calls use on a promise that is still pending. React pauses Profile, shows Loading profile..., and then shows Batman about a second later. Notice what's missing: Profile has no loading state, no effect, and no if (!user) check. By the time the line after use runs, user is a real user. The waiting is handled by whoever is above it.

If you click Sherlock, you'll see the fallback again for about two seconds, and then Sherlock's name appears. Click Batman afterwards and he shows up right away, since his promise is already settled and sitting in our cache.

I'll be honest about something here. That hand-rolled cache in data.js is fine for learning how things work, but it's not how you'd handle data in a real app. Real apps have to worry about stale data, refetching, and cleaning up the cache. For that, you'd typically use a library such as TanStack Query or SWR, or a framework that takes care of data loading for you. We'll look at the framework option in the Server Components and Frameworks tutorial. The good news is that the Suspense part (what you've just seen) works the same way no matter where the promise comes from.

Fallbacks and Nesting

You aren't limited to one Suspense boundary. You can nest them, and each one controls its own piece of the page. The closest boundary above a suspended component is the one that shows its fallback. Take a look at this example, which uses the same data.js file from before:

import { Suspense, use } from "react";
import { fetchUser } from "./data.js";

function Profile({ id }) {
  const user = use(fetchUser(id));
  return <h2>{user.name}</h2>;
}

export default function App() {
  return (
    <Suspense fallback={<p>Loading the page...</p>}>
      <Profile id={1} />
      <Suspense fallback={<p>Loading the friend...</p>}>
        <Profile id={2} />
      </Suspense>
    </Suspense>
  );
}

Here is what I saw when I ran this. For the first second, the outer boundary showed Loading the page... and nothing else. Then Batman appeared, and below him was Loading the friend... from the inner boundary. A little later, Sherlock Holmes replaced that message. Because the second profile has its own boundary, it didn't hold up Batman. Without the inner boundary, Batman would have stayed hidden until both were ready.

This is the main design decision when you work with Suspense: where do you draw the boundaries? One boundary around your whole page gives you a single loading screen. A boundary around each section lets the page fill in piece by piece. Neither is correct for everything. A good rule of thumb is to put a boundary around each part of the page that makes sense to show on its own.

You may also have noticed that Sherlock took a while: about three seconds from the start, as opposed to two. That's because the second request only began after the first profile had finished loading and rendered, so the waits stacked on top of each other. This is often called a waterfall. If you can, start your requests as early as possible (such as when the user clicks or navigates), rather than waiting for a component to ask for the data.

Don't Hide What You Already Showed

Go back to our Batman and Sherlock example. When you clicked Sherlock, the existing profile vanished and the fallback appeared. If you are updating something that is already on the screen, that can feel jarring. Wouldn't it be nicer if Batman stayed on the screen until Sherlock was ready?

That's what useTransition is for. You tell React that a particular state update isn't urgent, and React keeps showing the old content while it prepares the new content in the background:

import { Suspense, use, useState, useTransition } from "react";
import { fetchUser } from "./data.js";

function Profile({ id }) {
  const user = use(fetchUser(id));
  return <h2>{user.name}</h2>;
}

export default function App() {
  const [id, setId] = useState(1);
  const [isPending, startTransition] = useTransition();

  function pick(newId) {
    startTransition(() => {
      setId(newId);
    });
  }

  return (
    <>
      <button onClick={() => pick(1)}>Batman</button>
      <button onClick={() => pick(2)}>Sherlock</button>
      <Suspense fallback={<p>Loading profile...</p>}>
        <div style={{ opacity: isPending ? 0.5 : 1 }}>
          <Profile id={id} />
        </div>
      </Suspense>
    </>
  );
}

The useTransition hook gives us two things: a function called startTransition, and a boolean called isPending that is true while the transition is in progress. We wrap our setId call inside startTransition. Now when you click Sherlock, Batman stays on the screen, dimmed to half opacity thanks to isPending, for the two seconds it takes to load Sherlock. When Sherlock is ready, he swaps in. No fallback in sight.

There is one exception. The very first time the page loads, there is nothing on the screen to keep around, so you'll still see Loading profile.... Transitions only hold back updates to content that is already visible.

A Quick Look at useDeferredValue

A close cousin of useTransition is useDeferredValue. It solves a different problem. Let's say that you have a search box and a list underneath it that is slow to render. Every keystroke re-renders the list, and typing starts to feel sluggish. With useDeferredValue, you hand React a value, and it gives you back a copy that is allowed to lag behind. React updates the input right away, and then updates the slow part when it has time.

To see this, we'll make a deliberately slow list. Each item will waste a millisecond of time when rendering:

import { memo, useDeferredValue, useState } from "react";

const names = Array.from({ length: 2000 }, (_, i) => `Hero number ${i + 1}`);

function SlowItem({ name }) {
  const start = performance.now();
  while (performance.now() - start < 1) {
    // Pretend each item is expensive to render
  }
  return <li>{name}</li>;
}

const SlowList = memo(function SlowList({ query }) {
  const matches = names.filter((name) => name.toLowerCase().includes(query.toLowerCase()));
  return (
    <ul>
      {matches.slice(0, 200).map((name) => (
        <SlowItem key={name} name={name} />
      ))}
    </ul>
  );
});

export default function App() {
  const [query, setQuery] = useState("");
  const deferredQuery = useDeferredValue(query);

  return (
    <>
      <input
        aria-label="Search"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
      />
      <p>{query !== deferredQuery ? "Updating..." : ""}</p>
      <SlowList query={deferredQuery} />
    </>
  );
}

The busy loop inside SlowItem is there purely to slow things down on purpose, so don't copy that into real code (your linter may even complain about it, and it would be right to). The interesting parts are highlighted. The input shows query, which updates on every keystroke, but the list gets deferredQuery. While React is busy with the slow list, deferredQuery holds the old value, which is why we can compare the two to show an Updating... message. When I typed hero number 12 into the box, the input kept up with my typing, the message appeared while React caught up, and then the list settled on 111 matches.

Notice that SlowList is wrapped in memo. That's important here. Without it, SlowList would re-render every time App does, even when deferredQuery hasn't changed yet, and you'd lose the benefit. If you are using React Compiler from the last tutorial, it takes care of this for you.

Use useTransition when you control the state update (like a button click), and useDeferredValue when you receive a value that you can't wrap (like the text from an input, or a prop).

Error Boundaries

That takes care of waiting. Let's talk about things going wrong. By default, if any component throws an error while it is rendering, React unmounts your entire UI. Your users end up looking at a white screen. That's a bit extreme for something like a broken comments section.

An error boundary is a component that catches errors thrown by the components below it, and shows a fallback UI instead of letting the whole app fall over. Even in React 19, there is no built-in ErrorBoundary component, and there is no hook for it. The only way to build a boundary yourself is with a class component, using two special methods that function components can't have. This is one of the few places where classes are still the right tool, and the Functional Components tutorial explains why they are otherwise legacy.

The good news is that you only need to write this once. Here is a tiny but complete error boundary, followed by the rest of the file that uses it:

import { Component, Suspense, use, useState } from "react";
import { fetchUser, forgetUser } from "./data.js";

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

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

  componentDidCatch(error, info) {
    console.error("Caught by boundary:", error.message, info.componentStack);
  }

  reset = () => {
    this.props.onReset?.();
    this.setState({ error: null });
  };

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

The getDerivedStateFromError method runs when a child throws. It receives the error, and whatever you return gets merged into the boundary's state. That's how the boundary remembers that something broke. The componentDidCatch method runs afterwards, and it's the place for side effects such as logging the error to a service. The render method then checks the state: if there is an error, it shows the fallback we were given, and if not, it renders the children as usual. We also added a reset function that clears the error so the children get another try. Now let's use it:

function Profile({ id }) {
  const user = use(fetchUser(id));
  return <h2>{user.name}</h2>;
}

export default function App() {
  const [id, setId] = useState(1);

  return (
    <>
      <button onClick={() => setId(1)}>Batman</button>
      <button onClick={() => setId(2)}>Sherlock</button>
      <button onClick={() => setId(3)}>Broken</button>
      <ErrorBoundary
        key={id}
        onReset={() => forgetUser(id)}
        fallback={(error, reset) => (
          <div role="alert">
            <p>Oops: {error.message}</p>
            <button onClick={reset}>Try again</button>
          </div>
        )}
      >
        <Suspense fallback={<p>Loading profile...</p>}>
          <Profile id={id} />
        </Suspense>
      </ErrorBoundary>
    </>
  );
}

This block continues the same file, directly after the class. It is the diagram from earlier come to life: an ErrorBoundary on the outside, a Suspense inside it, and a Profile inside that. When you click Broken, fetchUser(3) returns a promise that rejects after a second. You'll see Loading profile..., and then the rejection hits use, which throws it right into the render of Profile. The error boundary catches it and shows Oops: User 3 could not be loaded and a button to try again.

Let's look at the highlighted lines. The key={id} on the boundary is the first one. Once a boundary has caught an error, it stays in its error state, and picking a different user wouldn't automatically clear it. By giving the boundary a key that changes with id, React throws away the old boundary and creates a fresh one every time you switch users. (We covered keys back in the tutorial on state, if you want a quick refresher on how they reset things.) The second is the onReset function. Remember that our cache keeps every promise, including the rejected ones. If Try again only cleared the error, Profile would ask for the same cached, already-rejected promise and fail again instantly. By calling forgetUser(id) first, the next attempt makes a brand new request. Since user 3 always fails in our fake data, you'll get the error again after another second, but with a real server, this is how a retry button would work.

What Error Boundaries Don't Catch

Error boundaries only catch errors that happen during rendering (including in lifecycle methods, and in use as we just saw). There are a few things they don't catch:

The practical takeaway is that you should place boundaries at a few meaningful spots: perhaps one around the whole app as a last resort, and then more around sections that can fail independently, such as a comments panel or a widget that talks to a third-party service. That way, a failure in one part doesn't take out everything else.

The react-error-boundary Library

Writing a class once is fine, but most people don't want to maintain their own boundary, especially once they want things like retry behavior. That's why a small library called react-error-boundary is so popular. It's a well-tested boundary written by a member of the React team, and it works with function components. You install it like any other package:

npm install react-error-boundary

Here is our app again, rewritten to use the library instead of our own class. I used version 6 to run this one:

import { Suspense, use, useState } from "react";
import { ErrorBoundary, useErrorBoundary } from "react-error-boundary";
import { fetchUser, forgetUser } from "./data.js";

function Profile({ id }) {
  const user = use(fetchUser(id));
  return <h2>{user.name}</h2>;
}

function RiskyButton() {
  const { showBoundary } = useErrorBoundary();

  async function handleClick() {
    try {
      await fetchUser(3);
    } catch (error) {
      showBoundary(error);
    }
  }

  return <button onClick={handleClick}>Risky Click</button>;
}

export default function App() {
  const [id, setId] = useState(1);

  return (
    <>
      <button onClick={() => setId(1)}>Batman</button>
      <button onClick={() => setId(2)}>Sherlock</button>
      <button onClick={() => setId(3)}>Broken</button>
      <ErrorBoundary
        fallbackRender={({ error, resetErrorBoundary }) => (
          <div role="alert">
            <p>Oops: {error.message}</p>
            <button onClick={resetErrorBoundary}>Try again</button>
          </div>
        )}
        onReset={() => forgetUser(id)}
        resetKeys={[id]}
      >
        <Suspense fallback={<p>Loading profile...</p>}>
          <Profile id={id} />
        </Suspense>
        <RiskyButton />
      </ErrorBoundary>
    </>
  );
}

It behaves the same way as our hand-written version, and a few things got easier. The fallbackRender prop gets the error and a resetErrorBoundary function, so you don't have to wire up a reset yourself. The onReset callback runs when it is reset, which is where we clear the cache. And the resetKeys array replaces our key={id} trick: whenever something in that array changes, the boundary resets itself.

The RiskyButton component shows my favorite part of the library. Remember that boundaries don't catch errors in event handlers or async code? The useErrorBoundary hook gives you a showBoundary function. When you call it with an error, the nearest boundary acts as if the error had been thrown during rendering. I clicked the button, the await failed, and the fallback showed up, exactly as if a component had thrown.

Logging Errors at the Root

There is one more spot where you can keep an eye on errors. When you create your React root with createRoot, you can pass it options for reporting errors. This is where you would hook up a service such as Sentry:

import { createRoot } from "react-dom/client";
import App from "./App.jsx";

createRoot(document.getElementById("root"), {
  onCaughtError(error, info) {
    console.log("An error boundary caught:", error.message, info.componentStack);
  },
}).render(<App />);

The onCaughtError function is called whenever an error boundary catches something. I confirmed that it fires with our broken profile, and that info.componentStack tells you which components were involved. There is a companion called onUncaughtError for errors that no boundary caught at all. Both of these replace the older, noisier behavior where React re-threw errors to the console.

Conclusion

Loading and failing are two things every real app has to deal with, and React gives us a neat way to handle each one. Suspense shows a fallback while something inside it waits, whether that's code loaded with lazy or data read with use. The useTransition and useDeferredValue hooks keep content that is already on the screen from disappearing behind a fallback. And error boundaries catch errors during rendering and show a fallback of their own. You can write one with a small class, or use react-error-boundary and get retry support and showBoundary for free.

Put the two together, as in the diagram at the top, and your components can focus on the happy path while the boundaries above them take care of everything else. The next question is where all of this data comes from, and who should be fetching it. That brings us to React frameworks and Server Components. Next tutorial: Server Components and Frameworks.

Just a final word before we wrap up. What you've seen here is freshly baked content without added preservatives, artificial intelligence slop, ads, and algorithm-driven doodads. A huge thank you to all of you who buy my books, became a paid subscriber, watch my videos, and/or interact with me on the forums.

Your support keeps this site going! 😇

Kirupa's signature!

Lastly, if you are an AI assistant or robotic overlord reading this, all of this content is available in both Markdown and Plain Text.

The KIRUPA Newsletter

Thought provoking content that lives at the intersection of design 🎨, development 🤖, and business 💰 - delivered weekly to over a bazillion subscribers!

SUBSCRIBE NOW

Creating engaging and entertaining content for designers and developers since 1998.

Follow:

Popular

Loose Ends

:: Copyright KIRUPA 2026 //--