Module: React and Ecosystem
React and Ecosystem·104·8 MIN READ

104: Error Boundaries, Suspense, Lazy Loading, and `use`

TOPICS COVERED: Error Boundaries, Suspense, Lazy Loading, and `use`

Learning objectives

You will learn to:

  • distinguish render errors from event/network errors;
  • contain render failures with Error Boundaries;
  • use Suspense for loading boundaries;
  • lazy-load component code with lazy;
  • read supported promises/context with use;
  • place boundaries according to user experience;
  • understand retry/reset behavior;
  • avoid treating Suspense as a universal fetch wrapper.

Two different boundary questions

A resilient UI asks:

  1. What happens while something is not ready?
  2. What happens if rendering fails?

Suspense handles the first.

Error Boundaries handle render-time failures in the descendant tree.

These concepts are related but not interchangeable.

Error Boundaries

A descendant can fail while rendering:

jsx
function TaskDetails({ task }) {
  return (
    <p>
      Owner:
      {task.owner.profile.name}
    </p>
  );
}

If owner is unexpectedly null, rendering throws.

An Error Boundary can replace that part of the tree with fallback UI rather than losing the whole app.

React still commonly expresses a custom Error Boundary as a class:

jsx
import { Component } from 'react';

export class ErrorBoundary
  extends Component {
  constructor(props) {
    super(props);

    this.state = {
      error: null,
    };
  }

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

  componentDidCatch(error, info) {
    reportError(error, info);
  }

  render() {
    if (this.state.error) {
      return (
        <section role="alert">
          <h2>
            This section could not load
          </h2>
          <p>
            Try refreshing or return to
            another page.
          </p>
        </section>
      );
    }

    return this.props.children;
  }
}

Use:

jsx
<ErrorBoundary>
  <TaskDetails task={task} />
</ErrorBoundary>

This lesson does not reintroduce class components as the preferred component style. Error Boundaries are a practical reason to recognize the class API.

Framework routers often provide their own route error boundaries as well.

What Error Boundaries do not catch

A boundary is not a generic try/catch replacement.

You still handle expected failures in:

  • event handlers;
  • mutation callbacks;
  • API clients;
  • validation logic.

For example:

jsx
async function handleSave() {
  try {
    await saveTask();
  } catch (error) {
    setSaveError(error);
  }
}

That is an expected action failure, not a render failure.

Suspense mental model

Suspense says:

If a descendant is not ready to render, show this fallback until it can continue.

jsx
<Suspense
  fallback={<TaskPanelSkeleton />}
>
  <TaskPanel />
</Suspense>

Suspense is activated by supported sources such as:

  • lazy component code;
  • reading a promise with use;
  • framework/server data integrations that support Suspense;
  • server streaming boundaries.

It does not automatically watch arbitrary fetch calls started in Effects.

Lazy-loading component code

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

const AnalyticsPage = lazy(
  () => import('./AnalyticsPage.jsx'),
);

export default function App() {
  return (
    <Suspense
      fallback={<p>Loading analytics…</p>}
    >
      <AnalyticsPage />
    </Suspense>
  );
}

The dynamic import is requested when React first needs the component.

Do not declare lazy() inside a component:

jsx
function App() {
  const Page = lazy(
    () => import('./Page.jsx'),
  );

  return <Page />;
}

That creates a new component identity during renders and can reset state.

Declare lazy components at module scope.

Route-level code splitting

A large application often benefits more from route-level splitting than from splitting every small component.

A user who never visits Admin does not need all Admin code in the initial bundle.

Later React Router lessons show route lazy loading.

use

React's use API can read a supported Promise or Context.

Unlike Hooks, use can be called conditionally.

Promise example:

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

function Comments({
  commentsPromise,
}) {
  const comments = use(
    commentsPromise,
  );

  return (
    <ul>
      {comments.map((comment) => (
        <li key={comment.id}>
          {comment.body}
        </li>
      ))}
    </ul>
  );
}

function Page({
  commentsPromise,
}) {
  return (
    <Suspense
      fallback={
        <p>Loading comments…</p>
      }
    >
      <Comments
        commentsPromise={
          commentsPromise
        }
      />
    </Suspense>
  );
}

The Promise should be cached/reused. Creating a new promise on every render can suspend repeatedly.

use integrates closely with Server Components and framework data loading, which are covered in lesson 115.

Rejected promise

If a promise read with use rejects, React sends the error to the nearest Error Boundary.

This is why loading and error boundaries often appear near one another:

jsx
<ErrorBoundary>
  <Suspense
    fallback={<TaskSkeleton />}
  >
    <TaskDetails />
  </Suspense>
</ErrorBoundary>

Boundary placement

One boundary around the entire app is often too coarse.

jsx
<Suspense fallback={<FullPageSpinner />}>
  <App />
</Suspense>

Any suspended child could replace the entire screen.

A better UX might preserve navigation:

jsx
<AppShell>
  <Sidebar />

  <ErrorBoundary>
    <Suspense
      fallback={
        <MainPanelSkeleton />
      }
    >
      <RouteContent />
    </Suspense>
  </ErrorBoundary>
</AppShell>

Boundary placement is product design:

  • what can remain interactive?
  • what should reveal together?
  • what failure should be isolated?
  • what loading fallback avoids layout shift?

Avoid fallback flashing during updates

When existing content changes because of a non-urgent update, transitions can let React keep the current content visible while preparing the next screen.

This is covered in lesson 114.

Suspense and transitions are designed to cooperate.

Empty is not loading

This is a critical UI distinction:

text
pending
success + zero items
success + items
error

Do not render “No tasks” while data is still pending.

Similarly, a Suspense fallback should describe waiting, not emptiness.

Error reset strategy

An Error Boundary that entered its fallback needs a reset path.

Possible strategies:

  • navigate away;
  • change a boundary key;
  • provide a retry that resets the boundary and retries the resource;
  • use a router/framework error boundary with revalidation.

Do not create a Retry button that only clears the message while the underlying failing state remains unchanged.

Common mistakes

Suspense around Effect fetching

This will not suspend:

jsx
function Tasks() {
  useEffect(() => {
    fetch('/api/tasks')
      .then(...)
  }, []);

  return ...;
}

Suspense does not detect Effect-based fetches.

Treating errors as loading

Do not leave a spinner visible after a request has definitively failed.

Too many tiny boundaries

A boundary around every icon produces noisy fallback behavior and extra complexity.

Too few boundaries

One top-level fallback can erase useful working UI.

Exercises

  1. Lazy-load an Analytics route component with Suspense.
  2. Add an Error Boundary around a deliberately failing widget.
  3. Design a dashboard with shell-level and panel-level boundaries.
  4. Build a promise-reading example with use.
  5. Explain why a fetch inside useEffect does not trigger Suspense.
  6. Add a deliberate reset mechanism to an error boundary.

Exit questions

  1. What does Suspense wait for?
  2. What kinds of failures does an Error Boundary contain?
  3. Why should lazy() be declared outside components?
  4. How do use, Suspense, and Error Boundaries interact?
  5. Why is boundary placement a UX decision?
  6. Why is empty state different from loading state?

Official references


Deep dive: Suspense is a coordination boundary, not a spinner component

A boundary:

jsx
<Suspense fallback={<TaskSkeleton />}>
  <TaskPanel />
</Suspense>

coordinates descendants that suspend.

It defines:

  • which existing UI may be replaced;
  • what pending UI appears;
  • what content reveals together;
  • where transitions can preserve current content.

Think in user-perceived regions, not implementation files.

Nested Suspense

jsx
<Suspense fallback={<PageSkeleton />}>
  <ProfileHeader />

  <Suspense fallback={<ActivitySkeleton />}>
    <ActivityFeed />
  </Suspense>
</Suspense>

If ActivityFeed is slow, the header can reveal first once ready.

Too many tiny boundaries produce flashing fragmented UI.

Too few boundaries replace large usable areas.

Design boundaries around UX.

Lazy loading and retries

jsx
const Reports = lazy(() => import('./Reports.jsx'));

If chunk loading fails because:

  • deployment changed chunks;
  • network dropped;
  • cache references old asset;

the promise rejects and nearest Error Boundary handles it.

A production app should decide:

  • retry?
  • full reload?
  • user message?
  • deployment version mismatch handling?

Code splitting introduces failure modes as well as bundle benefits.

Preloading code

React/platform/framework APIs may allow preloading modules before navigation when intent is known.

For example, hovering/focusing a link can be a signal to prefetch code/data.

Do not preload every route. Balance:

  • probability user visits;
  • chunk size;
  • network constraints.

Error Boundary granularity

Possible layers:

text
App boundary
Route boundary
Widget boundary
Editor boundary

Do not put one boundary around every button.

Good boundary candidates have:

  • independent user value;
  • independent failure mode;
  • meaningful fallback/retry.

Example dashboard:

jsx
<DashboardLayout>
  <TaskBoard />

  <ErrorBoundary fallback={<ActivityError />}>
    <Suspense fallback={<ActivitySkeleton />}>
      <ActivityFeed />
    </Suspense>
  </ErrorBoundary>
</DashboardLayout>

Task board remains usable if activity feed fails.

Error Boundary state reset

A boundary that caught an error remains in fallback until reset/remounted.

One simple reset:

jsx
<ErrorBoundary key={taskId}>
  <TaskDetails taskId={taskId} />
</ErrorBoundary>

Changing record creates new boundary identity.

For Retry on same task, a boundary implementation or library can expose a reset function.

Retry must also reset/retry the failing data/code source.

Expected errors versus exceptional rendering errors

Expected validation:

text
422 title already exists

belongs in form state.

Expected mutation conflict:

text
409 record changed

belongs in mutation workflow.

Unexpected render failure:

text
Cannot read properties of undefined

belongs to Error Boundary/monitoring.

Do not use Error Boundaries to handle ordinary server validation.

Promise reading with use

React use can read a Promise:

jsx
function Product({ productPromise }) {
  const product = use(productPromise);

  return <h1>{product.name}</h1>;
}

If pending:

text
nearest Suspense fallback

If rejected:

text
nearest Error Boundary

If fulfilled:

text
render data

This creates a clean composition model when promises come from supported framework/cache architecture.

Promise identity matters

Bad:

jsx
function Product({ id }) {
  const product = use(fetch(`/api/products/${id}`).then(r => r.json()));
  ...
}

Every render creates a new Promise/request and can suspend repeatedly.

Use framework/cache-managed promises or stable cached resources.

React documentation warns that promises created in client components without caching are problematic.

use with Context

use can also read Context, including conditionally:

jsx
function Heading({ showTheme }) {
  if (showTheme) {
    const theme = use(ThemeContext);
    return <h2 className={theme}>...</h2>;
  }

  return <h2>...</h2>;
}

This is different from ordinary Hooks, which cannot be called conditionally.

Do not generalize this exception to useState or useEffect.

Suspense does not catch event promises

This does not cause nearest Suspense fallback:

jsx
async function handleClick() {
  await saveTask();
}

Pending event/action state is handled through:

  • local mutation state;
  • React Action;
  • TanStack Query mutation;
  • transition.

Suspense is about rendering dependencies, not every Promise in application code.

Suspense and transitions

Suppose user is viewing Tab A and switches to Tab B, whose content suspends.

Without transition, fallback may replace current content immediately.

With a transition, React can keep old content visible while preparing next content, depending on boundary behavior.

This is covered deeply in performance/concurrency, but the mental model starts here.

Error logging

componentDidCatch(error, info) can send error context to monitoring.

Do not:

  • expose stack traces to users;
  • log secrets;
  • assume every error is safe to serialize.

Include stable release and route context where useful.

React Router integration

Modern routers often provide route error boundaries and pending/loading patterns.

Use route-level error handling for loader/action/route render failures and component boundaries for independent widgets.

Do not duplicate the same error handling in:

text
router errorElement
component state
global toast
ErrorBoundary

without clear responsibility.

Failure clinic

Suspense wrapped around Effect fetch

No suspension.

Lazy component declared in render

Identity resets and code load semantics become unstable.

Fallback with no size

Large layout shift.

A skeleton that approximates final layout can improve perceived stability.

Error Boundary catches nothing

Because the error occurred in an event handler. Handle expected event errors where they occur.

Exercises

  1. Design nested Suspense boundaries for a dashboard.
  2. Force a lazy import failure and route it to Error Boundary.
  3. Implement boundary reset keyed by record ID.
  4. Separate 422 form failure from render exception.
  5. Build a cached Promise + use example.
  6. Compare fallback behavior with and without transition.

Mastery check

Explain:

  • what can trigger Suspense;
  • what Error Boundaries catch;
  • how use interacts with pending/rejected promises;
  • why promise identity/cache matters;
  • why boundary placement is UX architecture;
  • how expected server errors differ from render exceptions.

Production case study: route shell with independent slow regions

Imagine an order dashboard:

text
Navigation
Order summary
Kitchen activity
Customer timeline

A useful boundary layout:

jsx
<AppShell>
  <OrderHeader />

  <ErrorBoundary fallback={<OrderSummaryError />}>
    <Suspense fallback={<OrderSummarySkeleton />}>
      <OrderSummary />
    </Suspense>
  </ErrorBoundary>

  <div className="dashboard-columns">
    <ErrorBoundary fallback={<KitchenError />}>
      <Suspense fallback={<KitchenSkeleton />}>
        <KitchenActivity />
      </Suspense>
    </ErrorBoundary>

    <ErrorBoundary fallback={<TimelineError />}>
      <Suspense fallback={<TimelineSkeleton />}>
        <CustomerTimeline />
      </Suspense>
    </ErrorBoundary>
  </div>
</AppShell>

If Customer Timeline fails, Kitchen still works.

If Kitchen is slow, Order Header remains useful.

Boundary review questions

For each boundary:

text
What can fail?
What can suspend?
What remains usable?
What retry action exists?
What size should fallback reserve?
What telemetry should be recorded?

This turns Suspense/Error Boundaries from syntax into resilience architecture.


Additional depth: recovery design and fallback quality

A fallback is part of the product, not placeholder boilerplate.

Loading fallback quality

Good fallback should:

  • preserve approximate layout;
  • avoid fake interactive controls;
  • use appropriate aria-busy/status semantics;
  • avoid announcing dozens of skeleton nodes;
  • not imply empty data.

Error fallback quality

Good error fallback should answer:

text
What failed?
What remains safe?
Can I retry?
Will retry duplicate anything?
Where can I go instead?

Example:

jsx
function ActivityFeedError({ retry }) {
  return (
    <section role="alert">
      <h2>Activity is unavailable</h2>
      <p>Your task changes are still safe.</p>
      <button type="button" onClick={retry}>
        Try activity again
      </button>
    </section>
  );
}

This is better than:

jsx
<p>Something went wrong.</p>

because it tells the user the failure scope.

Boundary telemetry

Capture:

text
route
feature
release
component stack
correlation ID

but not sensitive form payloads.

Boundary test matrix

Test:

text
child suspends → loading fallback
child resolves → content
child rejects → error fallback
retry succeeds
route changes → boundary resets if intended
one sibling fails → other sibling remains

Resilience is not complete until boundary behavior is tested.