Fix: React Suspense Not Working — Boundary Not Catching or Fallback Not Showing
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 indefinitelyOr in Next.js App Router, loading.tsx doesn’t appear during navigation:
// Users see no loading indicator when navigating between pagesWhy 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: trueis 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
asynccomponents directly —async 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 syntax —React.lazy()requires a function that returns a dynamicimport(), not the import result directly.- Missing
Suspensewrapper — if a lazy component is rendered without aSuspenseancestor, React throws an error instead of showing a fallback. ErrorBoundarycatching Suspense exceptions —ErrorBoundarycatches 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/useStateto 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-boundaryimport { 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 earlier — React.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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: i18next Not Working — Translations Missing, Language Not Switching, or Namespace Errors
How to fix i18next issues — react-i18next setup, translation file loading, namespace configuration, language detection, interpolation, pluralization, and Next.js integration.
Fix: Mapbox GL JS Not Working — Map Not Rendering, Markers Missing, or Access Token Invalid
How to fix Mapbox GL JS issues — access token setup, React integration with react-map-gl, markers and popups, custom layers, geocoding, directions, and Next.js configuration.
Fix: React PDF Not Working — PDF Not Rendering, Worker Error, or Pages Blank
How to fix react-pdf and @react-pdf/renderer issues — PDF viewer setup, worker configuration, page rendering, text selection, annotations, and generating PDFs in React.
Fix: Million.js Not Working — Compiler Errors, Components Not Optimized, or React Compatibility Issues
How to fix Million.js issues — compiler setup with Vite and Next.js, block() optimization rules, component compatibility constraints, automatic mode, and debugging performance gains.