Skip to content
C Codeloom
React

React Suspense and Lazy Loading Components

Use React.lazy and Suspense to code-split a React app, design fallback UI, place boundaries thoughtfully, and understand how Suspense extends to data fetching.

·6 min read · By Yash Kesharwani
Intermediate 9 min read

What you'll learn

  • How React.lazy turns a component into a code-split chunk
  • How Suspense renders a fallback while a chunk loads
  • Where to place Suspense boundaries for good UX
  • How code splitting interacts with routing
  • How Suspense extends from lazy loading to data fetching

Prerequisites

React.lazy and Suspense are React’s built-in tools for splitting a bundle into smaller pieces and loading them on demand. Together they cut down initial JavaScript size and let you stream UI in. This article walks through the mechanics, the UX considerations, and where Suspense is heading beyond components.

Why Code Splitting

A React app compiled into a single JavaScript bundle is convenient for a developer but expensive for a user. Every byte must be downloaded, parsed, and executed before the page becomes interactive. As features grow, that cost grows with them.

Code splitting breaks the bundle along logical seams: a heavy chart library only loads when the user opens the analytics screen, an admin panel only loads for admins, and so on. The user pays only for what they need.

React.lazy

React.lazy takes a function that returns a dynamic import() and returns a special component that React can suspend on.

import { lazy } from 'react';

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

The first time AnalyticsPanel is rendered, React triggers the dynamic import. The bundler (Vite, webpack, esbuild) sees this and emits a separate chunk file. While the chunk is loading, React suspends rendering at that component and looks up the tree for a Suspense boundary.

The default export of the imported module must be a React component.

// AnalyticsPanel.tsx
export default function AnalyticsPanel() {
  return <div>...</div>;
}

If you need a named export, wrap the dynamic import.

const Chart = lazy(() =>
  import('./charts').then((m) => ({ default: m.Chart }))
);

Suspense

Suspense is the component that catches a child’s “I am loading” signal and renders a fallback in its place.

import { Suspense } from 'react';

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <AnalyticsPanel />
    </Suspense>
  );
}

When AnalyticsPanel’s chunk has loaded, React swaps the spinner for the real UI. If the chunk fails to load (network drop, 404), the lazy import throws and an ErrorBoundary higher in the tree catches it. Pair Suspense with a boundary in production.

<ErrorBoundary fallback={<RetryPrompt />}>
  <Suspense fallback={<Spinner />}>
    <AnalyticsPanel />
  </Suspense>
</ErrorBoundary>

Placing Boundaries

The placement of Suspense is a UX decision. A single boundary at the top of the app means the entire page falls back to a spinner while any chunk loads. Many small boundaries mean individual sections can stream in independently.

function Dashboard() {
  return (
    <main>
      <Header />
      <Suspense fallback={<CardSkeleton />}>
        <RevenueCard />
      </Suspense>
      <Suspense fallback={<CardSkeleton />}>
        <UsersCard />
      </Suspense>
      <Suspense fallback={<ChartSkeleton />}>
        <Chart />
      </Suspense>
    </main>
  );
}

Now each card and the chart appear as soon as they are ready, instead of holding everything back. Skeleton UIs typically work better than spinners because they preserve layout and reduce the feeling of waiting.

The general rule: prefer many small boundaries close to the lazy content, with skeletons that match the final shape.

Splitting at Routes

Routes are the most common natural seam for code splitting. With React Router, you can lazy-load entire pages.

import { createBrowserRouter } from 'react-router-dom';
import { lazy, Suspense } from 'react';

const Settings = lazy(() => import('./routes/Settings'));
const Reports = lazy(() => import('./routes/Reports'));

const router = createBrowserRouter([
  {
    path: '/',
    element: <Root />,
    children: [
      {
        path: 'settings',
        element: (
          <Suspense fallback={<PageSkeleton />}>
            <Settings />
          </Suspense>
        ),
      },
      {
        path: 'reports',
        element: (
          <Suspense fallback={<PageSkeleton />}>
            <Reports />
          </Suspense>
        ),
      },
    ],
  },
]);

The user who never visits Reports never downloads its bundle. React Router’s data APIs also support a route-level lazy option that splits the loader and component together.

Preloading

Lazy chunks load on demand, but you can hint the browser to fetch them earlier. The simplest version is to trigger the dynamic import on a hover or focus event.

const Reports = lazy(() => import('./routes/Reports'));

function ReportsLink() {
  const prefetch = () => {
    import('./routes/Reports');
  };
  return (
    <Link to="/reports" onMouseEnter={prefetch} onFocus={prefetch}>
      Reports
    </Link>
  );
}

When the user clicks, the chunk is often already in cache. This is a small change with a big perceived improvement.

Suspense for Data, Not Just Lazy

Suspense is not limited to code splitting. It is a general mechanism for declaring “this UI depends on something async, show fallback meanwhile.” Frameworks like Next.js and libraries like Relay and React Query already integrate with it for data fetching.

The basic idea is that a data hook can throw a promise on the way to a value. Suspense catches that promise, shows the fallback, and re-renders when the promise resolves.

function User({ id }: { id: string }) {
  const user = useUser(id); // suspends until data is ready
  return <Profile user={user} />;
}

function App() {
  return (
    <Suspense fallback={<UserSkeleton />}>
      <User id="42" />
    </Suspense>
  );
}

You typically do not implement the suspense-throwing yourself; you use a library that does it. The benefits over useEffect-based loading are real: parallel data loading, no flash of empty state, and a single declarative way to express loading UI.

For local state-driven UI, the older patterns based on useState and useEffect are still appropriate. Suspense shines when async data drives what the user sees.

Common Pitfalls

A few things to keep in mind.

  • A lazy component with no Suspense ancestor will throw. Always provide a boundary.
  • Lazy components are not free. There is a network round trip for each chunk. Do not split components that are tiny or always rendered.
  • Default exports matter for React.lazy. If you forget, the dynamic import will resolve to an object and React will fail to render.
  • Avoid moving Suspense boundaries around constantly; that can cause the fallback to flicker on every navigation.

Wrap up

React.lazy and Suspense are the simplest way to ship less JavaScript on first load. Split along routes and heavy features, place boundaries close to the lazy components, use skeletons that match the real UI, and pair with error boundaries for resilience. The same mental model carries over to data fetching with Suspense-aware libraries, where the asynchronous nature of UI becomes a first-class concern.