EmpireUI
Get Pro
← Blog9 min read#suspense#react#loading

React Suspense Patterns: Loading, Error Boundaries and Nested Fallbacks

Master React Suspense with practical patterns for loading states, error boundaries, and nested fallbacks that won't make your users stare at spinners forever.

Developer coding React components on a dark monitor screen

What Suspense Actually Does (and Doesn't Do)

Suspense isn't magic. It's a coordination mechanism — a way for React to know that a subtree isn't ready to render yet, so it should show something else in the meantime. That's it. The nuance is in what "isn't ready" actually means, and that's where most developers go wrong.

When a component throws a Promise during render, React catches it, walks up the tree to find the nearest <Suspense> boundary, and renders the fallback prop instead. When that Promise resolves, React retries the suspended component. This is the entire mental model. Everything else — data fetching, code splitting, server components — is just different sources of the same Promise-throwing behavior.

Worth noting: Suspense has been stable for code splitting since React 16.6 (released in 2018), but data fetching Suspense only landed in a usable form with React 18 and the concurrent renderer. If you're on React 17 or earlier and someone tells you to use Suspense for data fetching, be skeptical. The APIs existed but weren't officially supported.

In practice, you probably already use Suspense via React.lazy() without thinking much about it. But combining it with error boundaries, nested fallbacks, and concurrent features is where things get genuinely useful — and occasionally weird.

Basic Suspense Setup With React.lazy

Start here before anything fancier. React.lazy() takes a function that returns a dynamic import() and gives you a component you can render normally — but the first render will suspend until the chunk is loaded.

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

const HeavyChart = lazy(() => import('./HeavyChart'));

function Dashboard() {
  return (
    <Suspense fallback={<div className="h-64 animate-pulse bg-gray-100 rounded-xl" />}>
      <HeavyChart />
    </Suspense>
  );
}

The fallback here is a skeleton div — 256px tall (h-64 = 64 × 4px), same rough dimensions as the chart. That's intentional. A spinner in the middle of a 500px canvas looks terrible. Match the fallback to the shape of what's loading.

One more thing — React.lazy() only works with default exports. Named exports require a small wrapper: lazy(() => import('./Charts').then(m => ({ default: m.HeavyChart }))). Annoying, but there it is.

Quick aside: you can wrap multiple lazy components in a single <Suspense> boundary. All of them need to resolve before React replaces the fallback. Sometimes that's what you want; sometimes you want each to render independently as it loads. Separate boundaries give you independent rendering.

Error Boundaries: The Part Everyone Skips

Here's a painful truth — a <Suspense> without an error boundary is a ticking clock. Eventually a network request fails, an import 404s, or a data source throws. Without an error boundary above the Suspense, the entire React tree unmounts. Your user sees a blank screen. Not great.

Error boundaries are class components. I know. In 2026 that feels like finding a cassette tape in your car, but React hasn't shipped a hooks-based alternative yet. You can wrap one in a small utility though:

import React, { Component, ReactNode } from 'react';

interface Props {
  fallback: ReactNode;
  children: ReactNode;
}

interface State {
  hasError: boolean;
  error: Error | null;
}

export class ErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false, error: null };

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, info: React.ErrorInfo) {
    // Log to your error tracker here
    console.error('Boundary caught:', error, info.componentStack);
  }

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

Compose it directly around your Suspense boundaries. The order matters — the error boundary goes outside the Suspense, not inside:

function SafeChart() {
  return (
    <ErrorBoundary fallback={<ChartError />}>
      <Suspense fallback={<ChartSkeleton />}>
        <HeavyChart />
      </Suspense>
    </ErrorBoundary>
  );
}

Honestly, the library react-error-boundary on npm solves the class component problem nicely. It gives you a functional-friendly API, a useErrorBoundary hook, and a resetKeys prop to automatically retry when specific values change. Worth pulling in rather than hand-rolling this every project.

Nested Fallbacks and Granular Loading States

Nested Suspense is where things get genuinely powerful. The rule is simple: React walks up the tree and renders the nearest ancestor Suspense boundary. So you can have coarse-grained outer boundaries for page-level loading and fine-grained inner ones for individual widgets.

function ProductPage({ id }: { id: string }) {
  return (
    // Page shell loads first
    <Suspense fallback={<PageSkeleton />}>
      <PageShell>
        {/* Hero image is critical, gets its own fine-grained boundary */}
        <Suspense fallback={<HeroSkeleton />}>
          <ProductHero id={id} />
        </Suspense>

        {/* Reviews can lag behind without affecting hero */}
        <Suspense fallback={<ReviewsSkeleton />}>
          <Reviews id={id} />
        </Suspense>

        {/* Recommendations are lowest priority */}
        <Suspense fallback={<RecoSkeleton />}>
          <Recommendations id={id} />
        </Suspense>
      </PageShell>
    </Suspense>
  );
}

What you've built here is a waterfall-aware layout. Hero renders as soon as its data arrives. Reviews and Recommendations render independently. Without the inner boundaries, a slow recommendation API would block the hero from showing — even if that data arrived in 50ms.

That said, don't go overboard. Every Suspense boundary is a potential flicker point. If three widgets all load in under 100ms, having three separate skeletons flash in sequence looks worse than one boundary that waits for all three. Profile before you split.

For the skeletons themselves, don't just throw a spinner in. Build shapes that match your real content. If you're building glass-style cards, check out the glassmorphism components on Empire UI — the frosted panel aesthetic looks especially good as a skeleton state with a subtle pulse animation over it.

Data Fetching with use() in React 19

React 19 shipped the use() hook, which you can pass a Promise directly. It suspends the component until the Promise resolves. This is the clean data-fetching story we've been waiting for since React 18 teased concurrent features.

import { use, Suspense } from 'react';

// Created outside the component — important!
const userPromise = fetchUser(userId);

function UserProfile() {
  // Suspends here until userPromise resolves
  const user = use(userPromise);

  return <div>{user.name}</div>;
}

function App() {
  return (
    <ErrorBoundary fallback={<UserError />}>
      <Suspense fallback={<UserSkeleton />}>
        <UserProfile />
      </Suspense>
    </ErrorBoundary>
  );
}

The catch — and it's a real one — is that the Promise must be created outside the component or memoized with useMemo. If you create it inside the render function, you get a new Promise every render, which causes an infinite suspend loop. This is the #1 mistake when starting with use().

// Wrong — new promise every render
function UserProfile({ userId }: { userId: string }) {
  const user = use(fetchUser(userId)); // Infinite loop!
  return <div>{user.name}</div>;
}

// Right — stable promise reference
function UserProfile({ promise }: { promise: Promise<User> }) {
  const user = use(promise);
  return <div>{user.name}</div>;
}

If you're on React 18 and can't upgrade yet, the pattern is to use a data-fetching library like TanStack Query or SWR with their own Suspense modes (suspense: true in TanStack Query v5). Same result, different path.

SuspenseList for Coordinated Reveals

SuspenseList is an experimental API that's been in React for years but still hasn't hit stable. It wraps multiple Suspense boundaries and controls the order in which they reveal. You can make them reveal top-to-bottom even if a lower one loads first, preventing that jarring pop-in effect where items render out of order.

import { SuspenseList, Suspense } from 'react';

function FeedPage() {
  return (
    <SuspenseList revealOrder="forwards" tail="collapsed">
      {feedItems.map((item) => (
        <Suspense key={item.id} fallback={<FeedItemSkeleton />}>
          <FeedItem id={item.id} />
        </Suspense>
      ))}
    </SuspenseList>
  );
}

The revealOrder="forwards" prop forces top-down reveals. tail="collapsed" means only one skeleton shows at a time below the last revealed item — instead of rendering 20 skeletons at once, you get a clean progressive load.

Look, this is experimental and you should check the React docs before shipping it. The API might shift. But in controlled environments — like a content feed or a card list — it's genuinely one of the nicest loading experiences you can build. For cards especially, pairing this with something from the Empire UI's collection of animated components makes the reveal feel intentional rather than accidental.

Worth noting: revealOrder="together" waits for all items to load before revealing any of them. Useful when you don't want the staggered effect — like a comparison table where showing partial data is misleading.

Retry Logic and Reset Patterns

Error boundaries catch errors, but by default they stay in the error state forever. You need to give users a way out. The simplest pattern is a "Try again" button that resets the boundary and retries the suspended component.

import { useErrorBoundary } from 'react-error-boundary';

function ChartError({ error }: { error: Error }) {
  const { resetBoundary } = useErrorBoundary();

  return (
    <div className="flex flex-col items-center gap-4 p-8 border border-red-200 rounded-xl">
      <p className="text-red-600 text-sm">Failed to load chart</p>
      <p className="text-gray-500 text-xs">{error.message}</p>
      <button
        onClick={resetBoundary}
        className="px-4 py-2 text-sm bg-black text-white rounded-lg hover:bg-gray-800"
      >
        Try again
      </button>
    </div>
  );
}

If you're using react-error-boundary, the resetKeys prop is even better than a manual button. Pass it a value that changes when the underlying data source changes — like a query key or a timestamp — and the boundary auto-resets when you'd expect a retry to succeed:

<ErrorBoundary
  FallbackComponent={ChartError}
  resetKeys={[chartDataKey]}
>
  <Suspense fallback={<ChartSkeleton />}>
    <HeavyChart dataKey={chartDataKey} />
  </Suspense>
</ErrorBoundary>

In practice, pair this with exponential backoff on your fetch function if you're hitting flaky APIs. The error boundary resets, the component suspends again, the fetch runs again with a slight delay. It won't win any elegance awards but it works and users expect "try again" to do something meaningful.

One last thing to think about: design your error states with the same care as your loading states. A red box with tiny grey text isn't good enough. Use the box shadow generator to give error cards some visual separation, and keep the copy actionable — "Failed to load" with a retry button beats a raw error message every time.

FAQ

Can I use Suspense for data fetching without React 19?

Yes — TanStack Query v5 and SWR both support Suspense mode on React 18. Pass suspense: true to your query config and wrap the component in a Suspense boundary. You don't get the use() hook but the behavior is the same.

Does an error boundary catch errors inside Suspense fallbacks?

No. The error boundary only catches errors thrown during rendering of its children tree. If your fallback component itself throws, you need another error boundary wrapping the parent. Keep fallbacks dead simple for this reason.

Why is my component suspending infinitely?

Almost always a Promise stability issue — you're creating a new Promise inside the component on every render. Move the Promise creation outside the component or wrap it in useMemo with a stable dependency array.

Should every async component have its own Suspense boundary?

Not necessarily. Group components that should appear together under one boundary. Only split them when independent loading genuinely improves UX — separate boundaries mean separate skeleton flashes, which can look choppy if the data all arrives within a few hundred milliseconds.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

React Error Boundaries: Catching Crashes Without Losing Your MindReact Error Boundaries in 2026: Class, Hooks-Compat, Recovery UI10 Tailwind Component Patterns Every Developer Should KnowLanding Page Design Patterns in 2026: Above the Fold, Hero, CTA