10LOC
Reactintermediate

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

  • SettingsPanel is the lazy-loaded component. In a real app it'd be lazy(() => 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.
  • ErrorBoundary is a class component — as of React 19, error boundaries still require a class, since there's no hook equivalent for getDerivedStateFromError/componentDidCatch. static getDerivedStateFromError flips hasError to true, which switches render() to the fallback.
  • Suspense sits inside ErrorBoundary: 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 ErrorBoundary inside a Suspense only catches errors thrown after the suspended component resolves, not a rejected import(). Keep the error boundary as the outer layer for this specific case.
  • getDerivedStateFromError alone doesn't log anything — add componentDidCatch(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 keeps lineWaiver: 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 hasError to false (and often force a fresh import(), 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.