React Lazy Loading and Code Splitting with Suspense

Learn React Lazy Loading and Code Splitting with Suspense with practical examples, explanations, best practices, and real-world coding techniques.

Introduction to Lazy Loading in React

Lazy loading is a performance technique that delays loading a resource until it is needed. In React, it is commonly used to load components only when users navigate to a particular page or open a specific feature. Instead of including every component in the initial JavaScript bundle, an application can download additional code when required.

React provides lazy() and Suspense to support component-level lazy loading. These features work with JavaScript dynamic imports and bundlers that support code splitting. For a broader understanding of React, start with the React introduction and React setup guide.

What Is Code Splitting?

Code splitting divides an application's JavaScript into smaller files, often called chunks. Without code splitting, a large application may load code for dashboards, reports, settings, and administration pages even when a visitor only needs the home page.

With code splitting, the browser can download the code needed for the current screen first and request other chunks later. This can reduce the amount of JavaScript required for the initial visit, although the actual improvement depends on the application, bundler, caching, and network conditions.

How lazy loading works

  1. The browser loads the initial application bundle.
  2. React renders the components required for the current screen.
  3. A lazy component is requested when React needs to render it.
  4. The browser downloads the corresponding JavaScript chunk.
  5. React renders the component after the module loads successfully.

Using React.lazy()

The lazy() function lets you declare a component that React loads when it is first rendered. It expects a function that dynamically imports a module. The imported module must resolve to an object whose default property contains the component.

Consider a separate Reports.jsx file that exports a component as its default export:

function Reports() {
  return (
    <section>
      <h2>Reports</h2>
      <p>View your application reports here.</p>
    </section>
  );
}

export default Reports;

Import it lazily from the parent component:

import { lazy } from 'react';

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

export default function App() {
  return (
    <main>
      <h1>Analytics Dashboard</h1>
      <Reports />
    </main>
  );
}

The component declaration is valid, but a lazy component must be rendered inside a Suspense boundary so React has a fallback to display while its module is loading.

Using Suspense for Loading States

Suspense displays fallback content while a supported child is waiting for its code to load. The fallback can be a message, spinner, skeleton, or another lightweight component.

import { lazy, Suspense } from 'react';

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

export default function App() {
  return (
    <main>
      <h1>Analytics Dashboard</h1>

      <Suspense
        fallback={<p role="status">Loading reports...</p>}
      >
        <Reports />
      </Suspense>
    </main>
  );
}

When the Reports module is loading, React renders the fallback. Once the module resolves, React displays the actual component. The role="status" attribute helps assistive technologies announce the loading message.

A single Suspense boundary can cover multiple lazy components. You can also create separate boundaries around independent sections so one part of a page can load without replacing the fallback for the entire screen.

Lazy Loading Multiple Components

Larger applications often have several features that users do not need immediately. Each can be loaded from a separate module. Keep lazy declarations outside component functions so they are not recreated during every render.

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

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

export default function Dashboard() {
  const [page, setPage] = useState('reports');

  return (
    <main>
      <nav aria-label="Dashboard sections">
        <button onClick={() => setPage('reports')}>
          Reports
        </button>

        <button onClick={() => setPage('settings')}>
          Settings
        </button>
      </nav>

      <Suspense fallback={<p role="status">Loading page...</p>}>
        {page === 'reports' ? <Reports /> : <Settings />}
      </Suspense>
    </main>
  );
}

In this example, the selected section determines which lazy component React renders. The browser can request the corresponding module when that component is first needed. The state-based navigation is illustrative; a real multi-page application may use a routing library to manage URLs and navigation history.

Lazy Loading with React Router

Route-level code splitting is one of the most useful applications of lazy loading. A user visiting a home page usually does not need the code for every dashboard, profile, or settings page immediately.

With a router that renders route elements, you can use React.lazy() and wrap the route content in Suspense:

import { lazy, Suspense } from 'react';
import {
  BrowserRouter,
  Routes,
  Route
} from 'react-router-dom';

const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));

export default function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<p role="status">Loading page...</p>}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/settings" element={<Settings />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

This example uses React Router's declarative route components. For more details about route configuration and navigation, see the React Router guide.

Lazy Loading Named Exports

By default, React.lazy() expects a default export. If a module exports a component by name, adapt the dynamic import so the named component is returned as the default property.

// Analytics.jsx
export function Analytics() {
  return <h2>Analytics</h2>;
}

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

const Analytics = lazy(() =>
  import('./Analytics').then((module) => ({
    default: module.Analytics
  }))
);

export default function App() {
  return (
    <Suspense fallback={<p>Loading analytics...</p>}>
      <Analytics />
    </Suspense>
  );
}

This approach is useful when you want to preserve named exports in a module while still loading a component lazily.

Lazy Loading Images and Other Resources

Component lazy loading is different from image lazy loading. For images that appear below the initial viewport, the browser's native loading="lazy" attribute can defer downloading until the image is closer to being needed.

export default function ProductGallery() {
  return (
    <section aria-label="Product gallery">
      <img
        src="/images/product-front.webp"
        alt="Front view of the product"
        width="640"
        height="480"
      />

      <img
        src="/images/product-side.webp"
        alt="Side view of the product"
        width="640"
        height="480"
        loading="lazy"
      />
    </section>
  );
}

Avoid lazy-loading the main above-the-fold image when it is likely to be the page's Largest Contentful Paint element. Delaying that image can make the page appear slower. Use image dimensions to reduce layout shifts and provide meaningful alternative text for accessibility.

Handling Lazy Loading Errors

Suspense handles the loading state, but it does not by itself provide a complete recovery UI for a failed dynamic import. If a network error or deployment mismatch prevents a chunk from loading, the rejected import needs an error boundary to display an appropriate message.

import { Component } from 'react';

export class ChunkErrorBoundary extends Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  render() {
    if (this.state.hasError) {
      return (
        <section role="alert">
          <p>This section could not be loaded.</p>
          <button onClick={() => window.location.reload()}>
            Reload application
          </button>
        </section>
      );
    }

    return this.props.children;
  }
}

Wrap the Suspense boundary with the error boundary so it can catch errors thrown by the lazy component:

<ChunkErrorBoundary>
  <Suspense fallback={<p role="status">Loading...</p>}>
    <Reports />
  </Suspense>
</ChunkErrorBoundary>

In production, log chunk-loading failures and provide a recovery strategy appropriate to your application. Reloading can help when a deployment has left the browser with references to outdated chunk files, but it should not be the only strategy for every error.

For a broader treatment of boundaries, API failures, and fallback interfaces, read the React error handling guide.

Common Lazy Loading Mistakes

  • Forgetting Suspense: A lazy component must be rendered within a Suspense boundary that can display fallback content.
  • Declaring lazy components inside another component: Define lazy components at module scope to avoid recreating component types during renders.
  • Using the wrong export: The dynamic import must resolve to a module with a default component export, or you must adapt a named export.
  • Splitting every tiny component: Excessive chunks can add request overhead and complexity without meaningful performance benefits.
  • Lazy-loading critical content: Delaying content users need immediately may make the initial screen feel slower.
  • Ignoring loading and error states: Provide clear feedback during loading and a useful recovery interface when a chunk fails.
  • Assuming lazy loading guarantees faster performance: Measure bundle sizes, network activity, and real user experience before and after the change.

Best Practices for React Lazy Loading

  • Prioritize route-level splitting for large pages and features that users do not always visit.
  • Keep lazy component declarations outside component functions.
  • Use meaningful Suspense fallbacks that preserve the expected layout when possible.
  • Choose boundary placement carefully so loading one section does not unnecessarily replace the entire page.
  • Pair Suspense with error boundaries to handle rejected lazy imports.
  • Use native image lazy loading for suitable below-the-fold images.
  • Analyze production bundles and test on slower networks before deciding where to split code.
  • Use preloading or prefetching strategically when you can predict which feature a user will need next.
  • Keep loading indicators accessible and ensure important content remains usable with keyboard navigation.

Conclusion

React lazy loading helps reduce the JavaScript required for an initial screen by loading components when they are needed. Use React.lazy() to define dynamically imported components and Suspense to provide loading fallbacks. Combine these tools with route-level code splitting, error boundaries, sensible image loading, and performance measurements to build responsive React applications.

Continue learning with React Suspense and transitions, React performance optimization, and React deployment.

Code Examples

import { lazy, Suspense } from "react";

const Profile = lazy(() => import("./Profile"));

export default function App() {
  return (
    <Suspense fallback={<p role="status">Loading profile...</p>}>
      <Profile />
    </Suspense>
  );
}
    
import { lazy, Suspense } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";

const Home = lazy(() => import("./pages/Home"));
const Dashboard = lazy(() => import("./pages/Dashboard"));

export default function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<p role="status">Loading page...</p>}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/dashboard" element={<Dashboard />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}
    
import { lazy, Suspense } from "react";

const Analytics = lazy(() =>
  import("./Analytics").then((module) => ({
    default: module.Analytics
  }))
);

export default function App() {
  return (
    <Suspense fallback={<p>Loading analytics...</p>}>
      <Analytics />
    </Suspense>
  );
}
    
export default function Gallery() {
  return (
    <section aria-label="Photo gallery">
      <img
        src="/images/featured.webp"
        alt="Featured image"
        width="640"
        height="480"
      />
      <img
        src="/images/secondary.webp"
        alt="Secondary image"
        width="640"
        height="480"
        loading="lazy"
      />
    </section>
  );
}
    

Pro Tip

Start with route-level code splitting for large pages that are not needed on the initial screen. Place lazy declarations at module scope, provide useful Suspense fallbacks, and use an error boundary for failed imports. Measure your production bundle and loading experience before adding more splitting.

🎨 IgKnight Docs

Master HTML, CSS, JS, Python, .NET and more with easy-to-follow lessons and practical code examples.

© 2025 IgKnight Learning Platform. All rights reserved.