React.lazy + Suspense with an error boundary fallback
Published November 3, 2026
import { lazy, Suspense, Component, type ReactNode } from "react";
// In a real app, SettingsPanelImpl would live in its own file and this
// would be lazy(() => import("./SettingsPanel")) instead -- this snippet
// is one self-contained file, so it wraps an inline component the same
// way a real dynamic import resolves: a promise for { default: Component }.
const SettingsPanelImpl = () => <p>Settings form goes here.</p>;
const SettingsPanel = lazy(() => Promise.resolve({ default: SettingsPanelImpl }));
class ErrorBoundary extends Component<{ fallback: ReactNode; children: ReactNode }, { hasError: boolean }> {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
return this.state.hasError ? this.props.fallback : this.props.children;
}
}
export const SettingsRoute = () => (
<ErrorBoundary fallback={<p>Couldn't load settings. Try refreshing.</p>}>
<Suspense fallback={<p>Loading settings…</p>}>
<SettingsPanel />
</Suspense>
</ErrorBoundary>
);What
Combining React.lazy with Suspense code-splits a component so it's fetched on demand; wrapping that in an error boundary means a failed chunk load shows a fallback instead of crashing the tree.
Why it matters
lazy(() => import(...)) returns a component that suspends while its module downloads — Suspense catches that and shows a loading fallback. But import() is a network request, and network requests fail: a flaky connection, an ad blocker, a deploy that just rotated the chunk hashes out from under a tab that's been open too long. Suspense only handles the pending case; a rejected import() promise is thrown as an error on the next render attempt, and without an error boundary above it, that error propagates up and can blank out far more of the page than just the panel that failed to load.
How it works
SettingsPanelis the lazy-loaded component. In a real app it'd belazy(() => import("./SettingsPanel")), a real dynamic import of its own file, not fetched until something tries to render it — this snippet inlines the component to stay self-contained, but the mechanics are identical either way.ErrorBoundaryis a class component — as of React 19, error boundaries still require a class, since there's no hook equivalent forgetDerivedStateFromError/componentDidCatch.static getDerivedStateFromErrorflipshasErrortotrue, which switchesrender()to thefallback.Suspensesits insideErrorBoundary: while the chunk is loading,Suspense's fallback shows; if the load fails, the error boundary catches the rejection and its fallback takes over instead.- The two fallbacks are deliberately different messages — "loading" vs. "couldn't load" — so a user can tell a slow network apart from a genuine failure.
Gotchas
- Boundary order matters: an
ErrorBoundaryinside aSuspenseonly catches errors thrown after the suspended component resolves, not a rejectedimport(). Keep the error boundary as the outer layer for this specific case. getDerivedStateFromErroralone doesn't log anything — addcomponentDidCatch(error, info)if you want the failure reported to Sentry or similar; it's omitted here to keep the boundary itself readable. That's also why this snippet keepslineWaiver: true: the class boilerplate an error boundary needs can't be trimmed without losing correctness.- Retrying isn't automatic. A "Try again" button typically needs to reset
hasErrortofalse(and often force a freshimport(), since some bundlers cache the failed promise).
Related
react-error-boundary(npm) — a maintained, hook-friendly wrapper around this exact class-component boilerplate, worth reaching for instead of hand-rolling it repeatedly.use()combined with a Suspense-integrated fetch — the same suspend/catch mechanics apply to thrown data-fetching promises, not just lazy-loaded components.