Skip to content

Fix: React Suspense Not Working — Boundary Not Catching or Fallback Not Showing

FixDevs ·

Quick Answer

How to fix React Suspense boundaries not triggering — lazy() import syntax, use() hook, data fetching libraries, ErrorBoundary vs Suspense, and Next.js loading.tsx.

The Problem

A React <Suspense> boundary doesn’t show the fallback during loading:

<Suspense fallback={<Spinner />}>
  <UserProfile userId={42} />  {/* Spinner never shows */}
</Suspense>

Or a lazy-loaded component triggers an error instead of a loading state:

Error: Element type is invalid: expected a string or a class/function
but got: object. You may have forgotten to use lazy() correctly.

Or the Suspense fallback shows, but never resolves to the actual component:

// Component renders but stays on the fallback indefinitely

Or in Next.js App Router, loading.tsx doesn’t appear during navigation:

// Users see no loading indicator when navigating between pages

Why This Happens

React Suspense works by catching “promises” thrown by components. Only specific patterns trigger Suspense:

  • React.lazy() — lazy-loaded components throw a promise until the import resolves. This is the one case that works out-of-the-box.
  • Data fetching libraries with Suspense support — React Query, SWR, and Relay can throw promises when suspense: true is enabled in their config.
  • React’s use() hook — available in React 19 and experimental versions. use(promise) suspends the component until the promise resolves.
  • Custom Suspense-compatible data fetching — requires manually throwing a promise from the component, which is non-trivial.

Common mistakes:

  • Using async components directlyasync function Component() doesn’t work in React 18 client components. It only works in React Server Components (Next.js App Router).
  • lazy() with wrong import syntaxReact.lazy() requires a function that returns a dynamic import(), not the import result directly.
  • Missing Suspense wrapper — if a lazy component is rendered without a Suspense ancestor, React throws an error instead of showing a fallback.
  • ErrorBoundary catching Suspense exceptionsErrorBoundary catches all thrown values. If it wraps a Suspense, it may catch the promise and show an error state instead.

Fix 1: Use React.lazy() Correctly

React.lazy() is the most reliable way to trigger Suspense — use it for code-split components:

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

// CORRECT — lazy() wraps a function that returns a dynamic import
const UserProfile = lazy(() => import('./UserProfile'));
const Dashboard = lazy(() => import('./Dashboard'));

// WRONG — passing the import result directly (not a function)
const UserProfile = lazy(import('./UserProfile'));  // TypeError

// WRONG — the dynamic import doesn't return a default export
// import('./utils') exports { helper } but no default
const Utils = lazy(() => import('./utils'));  // Error: no default export

// Fix for named exports — re-export as default or wrap:
const Utils = lazy(() =>
  import('./utils').then(module => ({ default: module.NamedComponent }))
);

// Usage — must be wrapped in Suspense
function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <UserProfile userId={42} />
    </Suspense>
  );
}

Lazy load with error handling:

import { Suspense, lazy } from 'react';
import ErrorBoundary from './ErrorBoundary';

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

function Dashboard() {
  return (
    <ErrorBoundary fallback={<p>Failed to load chart.</p>}>
      <Suspense fallback={<p>Loading chart...</p>}>
        <HeavyChart />
      </Suspense>
    </ErrorBoundary>
  );
}

Multiple lazy components with a single Suspense:

const UserList = lazy(() => import('./UserList'));
const UserStats = lazy(() => import('./UserStats'));

// Both resolve before the fallback disappears
function App() {
  return (
    <Suspense fallback={<FullPageSpinner />}>
      <UserList />
      <UserStats />
    </Suspense>
  );
}

Fix 2: Enable Suspense in React Query or SWR

React Query and SWR have Suspense support that must be explicitly enabled:

React Query (TanStack Query) v5:

import { useSuspenseQuery } from '@tanstack/react-query';

// Use useSuspenseQuery instead of useQuery
function UserProfile({ userId }) {
  // useSuspenseQuery throws a promise while fetching — triggers Suspense
  const { data: user } = useSuspenseQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  // No need to check for isLoading — component only renders when data is ready
  return <div>{user.name}</div>;
}

// Wrap with Suspense
function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <UserProfile userId={42} />
    </Suspense>
  );
}

React Query v4 (legacy):

// v4 — enable suspense per-query or globally
const { data } = useQuery({
  queryKey: ['user', userId],
  queryFn: fetchUser,
  suspense: true,   // Enable suspense mode
});

SWR:

import useSWR from 'swr';

function UserProfile({ userId }) {
  const { data: user } = useSWR(`/api/users/${userId}`, fetcher, {
    suspense: true,   // Throw promise while loading
  });

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

// Wrap with Suspense
<Suspense fallback={<Spinner />}>
  <UserProfile userId={42} />
</Suspense>

Fix 3: Use the use() Hook (React 19)

React 19 introduces use() for reading promises and context in components:

import { use, Suspense } from 'react';

// Create the promise outside the component (or pass as prop)
const userPromise = fetchUser(42);

function UserProfile({ userPromise }) {
  // use() suspends the component until the promise resolves
  const user = use(userPromise);

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

function App() {
  const [userPromise] = useState(() => fetchUser(42));

  return (
    <Suspense fallback={<Spinner />}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  );
}

Note: Don’t create the promise inside the component function — that creates a new promise on every render, causing infinite loading. Create the promise outside the component or use useMemo/useState to stabilize it.

Wrong pattern — promise created inside render:

// WRONG — new promise on every render = infinite loading
function UserProfile({ userId }) {
  const user = use(fetchUser(userId));  // fetchUser called on every render
  return <div>{user.name}</div>;
}

// CORRECT — stable promise reference
function UserProfile({ userPromise }) {
  const user = use(userPromise);   // Stable promise passed as prop
  return <div>{user.name}</div>;
}

Fix 4: Fix ErrorBoundary and Suspense Interaction

ErrorBoundary and Suspense must be correctly ordered. An ErrorBoundary that wraps Suspense catches Suspense promises if improperly implemented:

// Custom ErrorBoundary — must NOT catch promises (React handles those)
class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error) {
    // ONLY catch actual errors — React internally throws promises for Suspense
    // React 18+ filters promise throws from ErrorBoundary automatically
    return { hasError: true, error };
  }

  componentDidCatch(error, info) {
    console.error('Caught error:', error, info);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || <p>Something went wrong.</p>;
    }
    return this.props.children;
  }
}

// Correct nesting: ErrorBoundary wraps Suspense
// Suspense handles loading, ErrorBoundary handles load failures
function SafeUserProfile({ userId }) {
  return (
    <ErrorBoundary fallback={<p>Failed to load user.</p>}>
      <Suspense fallback={<Spinner />}>
        <UserProfile userId={userId} />
      </Suspense>
    </ErrorBoundary>
  );
}

Use react-error-boundary for a robust ErrorBoundary:

npm install react-error-boundary
import { ErrorBoundary } from 'react-error-boundary';
import { Suspense } from 'react';

function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div role="alert">
      <p>Something went wrong:</p>
      <pre>{error.message}</pre>
      <button onClick={resetErrorBoundary}>Try again</button>
    </div>
  );
}

function App() {
  return (
    <ErrorBoundary FallbackComponent={ErrorFallback}>
      <Suspense fallback={<Spinner />}>
        <UserProfile userId={42} />
      </Suspense>
    </ErrorBoundary>
  );
}

Fix 5: Fix Suspense in Next.js App Router

Next.js 13+ App Router has built-in Suspense support through loading.tsx files and <Suspense> components:

loading.tsx — automatic Suspense for the whole route segment:

// app/dashboard/loading.tsx
// This file automatically wraps app/dashboard/page.tsx in a Suspense boundary
export default function DashboardLoading() {
  return (
    <div className="animate-pulse">
      <div className="h-8 bg-gray-200 rounded w-1/4 mb-4" />
      <div className="h-64 bg-gray-200 rounded" />
    </div>
  );
}
// app/dashboard/page.tsx — can be async in Server Components
async function DashboardPage() {
  // Data fetching happens here — Next.js handles Suspense automatically
  const data = await fetchDashboardData();
  return <Dashboard data={data} />;
}

Manual <Suspense> for partial loading states:

// app/dashboard/page.tsx
import { Suspense } from 'react';

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      {/* Sidebar loads independently */}
      <Suspense fallback={<SidebarSkeleton />}>
        <Sidebar />
      </Suspense>

      {/* Main content loads independently */}
      <Suspense fallback={<ContentSkeleton />}>
        <MainContent />
      </Suspense>
    </div>
  );
}

Client Components in App Router still need lazy() for code splitting:

'use client';

import { lazy, Suspense } from 'react';

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

export function EditorPage() {
  return (
    <Suspense fallback={<p>Loading editor...</p>}>
      <HeavyEditor />
    </Suspense>
  );
}

Fix 6: Build a Simple Suspense-Compatible Data Source

If you’re not using a library with Suspense support, here’s the pattern for building a Suspense-compatible data source:

// A simple "resource" that throws a promise until data is ready
type Resource<T> =
  | { status: 'pending'; promise: Promise<void> }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

function createResource<T>(promise: Promise<T>): { read: () => T } {
  let resource: Resource<T> = {
    status: 'pending',
    promise: promise.then(
      (data) => { resource = { status: 'success', data }; },
      (error) => { resource = { status: 'error', error }; }
    ),
  };

  return {
    read(): T {
      if (resource.status === 'pending') {
        throw resource.promise;    // Suspense catches this
      }
      if (resource.status === 'error') {
        throw resource.error;      // ErrorBoundary catches this
      }
      return resource.data;
    },
  };
}

// Usage
const userResource = createResource(fetchUser(42));

function UserProfile() {
  const user = userResource.read();  // Throws until data is ready
  return <div>{user.name}</div>;
}

Note: This pattern is educational. In production, use React Query, SWR, or React’s use() hook (React 19) instead.

Still Not Working?

Suspense in React 17 and earlierReact.lazy() is supported in React 16.6+, but use() and SuspenseList require React 18+. Data fetching Suspense (beyond lazy) wasn’t stable until React 18.

Concurrent Mode required for full Suspense — React 18’s createRoot enables concurrent mode. If you’re using ReactDOM.render() (legacy mode), Suspense support is limited to lazy loading only.

Fallback flicker — if the loading state resolves too quickly, the fallback briefly shows and disappears, causing a flash. Add a minimum delay or use startTransition to defer showing the loading state:

import { startTransition, useState } from 'react';

function Navigation() {
  const [page, setPage] = useState('home');

  function navigate(newPage) {
    startTransition(() => {
      setPage(newPage);   // Suspense transition — no fallback flash for fast loads
    });
  }
  // ...
}

Nested Suspense boundaries — React resolves the nearest ancestor Suspense boundary. A Suspense inside another Suspense only shows the inner fallback. If both are loading, only the outer fallback shows until the inner component starts rendering.

For related React issues, see Fix: React Hydration Error and Fix: React Query Stale Data.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles