Stackvora.tech

Code Splitting With React.lazy and Suspense

Optimizing the initial load performance of single-page applications is critical for user experience and SEO. Large JavaScript bundles, especially those…

Optimizing the initial load performance of single-page applications is critical for user experience and SEO. Large JavaScript bundles, especially those containing code not immediately required, significantly delay Time to Interactive (TTI). React offers built-in mechanisms, React.lazy() and <Suspense>, to implement code splitting efficiently, deferring the loading of components until they are actually rendered. This approach dramatically reduces the initial bundle size, leading to faster page loads and improved perceived performance.

Understanding Code Splitting in React

Code splitting is a technique that breaks down a large JavaScript bundle into smaller, on-demand chunks. When the user navigates to a part of the application that requires a specific chunk, that chunk is then loaded. In the context of React, this typically means loading component code only when it's needed, rather than bundling everything into a single file at build time.

How Bundlers Facilitate Code Splitting

Modern bundlers like Webpack, Rollup, and Parcel support code splitting out of the box. They analyze import statements and can automatically create separate bundles for modules that are dynamically imported. import() is a standard ECMAScript proposal that allows you to load modules asynchronously. React's React.lazy() leverages this dynamic import syntax.

The Role of React.lazy()

React.lazy() is a function that lets you render a dynamic import as a regular component. It takes a function that returns a Promise, which then resolves to a module with a default export containing a React component. The key here is that the component's code is not loaded until it's actually rendered by React.


import React, { lazy } from 'react';

// Before: Direct import, code bundled upfront
// import AdminDashboard from './AdminDashboard';

// After: Dynamic import wrapped in lazy(), code split
const AdminDashboard = lazy(() => import('./AdminDashboard'));
const UserProfile = lazy(() => import('./UserProfile'));

function App() {
  const [isAdmin, setIsAdmin] = React.useState(false);
  const [showProfile, setShowProfile] = React.useState(false);

  return (
    <div>
      <button onClick={() => setIsAdmin(true)}>Show Admin Dashboard</button>
      <button onClick={() => setShowProfile(true)}>Show User Profile</button>

      {isAdmin && <AdminDashboard />}
      {showProfile && <UserProfile />}
    </div>
  );
}

In this example, AdminDashboard.js and UserProfile.js will be split into separate JavaScript chunks by the bundler. Their code will only be fetched from the server when isAdmin or showProfile becomes true, respectively, causing the component to be rendered.

Implementing Suspense for Fallback UI

When a lazy-loaded component is rendered, its code might not be immediately available. The network request to fetch the chunk takes time. During this period, React needs a way to display a temporary UI to the user instead of a blank screen or an error. This is where the <Suspense> component comes in.

<Suspense> is a built-in React component that lets you "suspend" rendering part of your component tree until certain conditions are met (e.g., data is fetched, lazy component code is loaded). It takes a fallback prop, which accepts any React element that will be rendered while the lazy component is loading.


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

const AdminDashboard = lazy(() => import('./AdminDashboard'));
const UserProfile = lazy(() => import('./UserProfile'));

function App() {
  const [isAdmin, setIsAdmin] = React.useState(false);
  const [showProfile, setShowProfile] = React.useState(false);

  return (
    <div>
      <h1>Application Header</h1>
      <button onClick={() => setIsAdmin(true)}>Show Admin Dashboard</button>
      <button onClick={() => setShowProfile(true)}>Show User Profile</button>

      <Suspense fallback={<div>Loading Admin Dashboard...</div>}>
        {isAdmin && <AdminDashboard />}
      </Suspense>

      <Suspense fallback={<div>Loading User Profile...</div>}>
        {showProfile && <UserProfile />}
      </Suspense>

      <p>Some other content that loads immediately.</p>
    </div>
  );
}

Multiple lazy components can be wrapped within a single <Suspense> boundary. If any of its children (or their children) are lazy-loaded and not yet available, the fallback will be displayed. When all components within that <Suspense> boundary are ready, the fallback is replaced by the actual content.


<Suspense fallback={<div>Loading content...</div>}>
  <AdminDashboard />
  <UserProfile />
</Suspense>

In this configuration, the "Loading content..." fallback will be displayed until both AdminDashboard and UserProfile components have their code loaded.

Code Splitting with React Router

A common and highly effective strategy for code splitting is to apply it at the route level. When a user navigates to a different URL, only the components associated with that route need to be loaded. This is where React.lazy() integrates seamlessly with client-side routing libraries like React Router.

For React Router v6.4+ (and recommended for all modern React Router apps), the preferred approach is to use the new lazy() data routes API. This API not only allows for code splitting the component but also for parallel loading of route data.


// router.jsx
import React, { lazy, Suspense } from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';

// Root Layout Component (e.g., Navbar, Footer)
import AppLayout from './AppLayout';

// Lazy-loaded components for routes
const HomePage = lazy(() => import('./pages/HomePage'));
const AboutPage = lazy(() => import('./pages/AboutPage'));
const SettingsPage = lazy(() => import('./pages/SettingsPage'));
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
const ErrorPage = lazy(() => import('./pages/ErrorPage'));

// Define a loader function for DashboardPage (example of data loading)
async function dashboardLoader() {
  const response = await fetch('/api/dashboard-data');
  if (!response.ok) {
    throw new Response("Not Found", { status: 404 });
  }
  return response.json();
}

const router = createBrowserRouter([
  {
    path: '/',
    element: <AppLayout />,
    errorElement: (
      <Suspense fallback={<div>Loading Error Page...</div>}>
        <ErrorPage />
      </Suspense>
    ),
    children: [
      {
        index: true, // This is the default child route for '/'
        element: (
          <Suspense fallback={<div>Loading Home...</div>}>
            <HomePage />
          </Suspense>
        ),
      },
      {
        path: 'about',
        element: (
          <Suspense fallback={<div>Loading About...</div>}>
            <AboutPage />
          </Suspense>
        ),
      },
      {
        path: 'settings',
        element: (
          <Suspense fallback={<div>Loading Settings...</div>}>
            <SettingsPage />
          </Suspense>
        ),
      },
      {
        path: 'dashboard',
        element: (
          <Suspense fallback={<div>Loading Dashboard...</div>}>
            <DashboardPage />
          </Suspense>
        ),
        loader: dashboardLoader, // React Router will fetch data in parallel
      },
    ],
  },
]);

function App() {
  return <RouterProvider router={router} />;
}

export default App;

In this setup:

  • Each page component (HomePage, AboutPage, etc.) is lazy-loaded.
  • Each lazy-loaded route component is wrapped in its own <Suspense> boundary, allowing for route-specific loading indicators.
  • For the /dashboard route, the loader function fetches data. React Router ensures that the DashboardPage component's code and the data from dashboardLoader are fetched in parallel, enhancing perceived performance. The component won't render until both the code and data are available.

Lazy Data Routes with React Router (preferred)

React Router's lazy property on route objects simplifies this even further by handling the lazy() wrapper and Suspense internally for the component, and also allows defining data loaders alongside the component itself. This is the most ergonomic approach for route-based code splitting and data loading.


// router.jsx
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import AppLayout from './AppLayout'; // Main layout, not lazy-loaded

const router = createBrowserRouter([
  {
    path: '/',
    element: <AppLayout />,
    children: [
      {
        index: true,
        // Using React Router's `lazy` property
        lazy: async () => {
          let { HomePage } = await import('./pages/HomePage');
          return { Component: HomePage };
        },
      },
      {
        path: 'settings',
        // Example with component and loader
        lazy: async () => {
          let { SettingsPage, settingsLoader } = await import('./pages/SettingsPage');
          return { Component: SettingsPage, loader: settingsLoader };
        },
      },
      {
        path: 'dashboard',
        lazy: async () => {
          let { DashboardPage, dashboardLoader } = await import('./pages/DashboardPage');
          return { Component: DashboardPage, loader: dashboardLoader };
        },
        errorElement: <h2>Failed to load Dashboard!</h2> // Specific error for this route
      },
    ],
  },
]);

function App() {
  return <RouterProvider router={router} fallbackElement={<p>Initial page loading...</p>} />;
}

export default App;

// In ./pages/SettingsPage.js
export async function settingsLoader() {
  const response = await fetch('/api/user-settings');
  return response.json();
}
export function SettingsPage() {
  // ... useLoaderData() here
  return <h2>Settings Page</h2>;
}

The fallbackElement on <RouterProvider> provides a global fallback for the very first route load. Individual lazy route elements don't directly use <Suspense> anymore when using the lazy property on routes, as React Router manages this internally.

Measuring and Optimizing Bundle Sizes

After implementing code splitting, it's crucial to measure its effectiveness. Tools like Webpack Bundle Analyzer can visualize the contents of your bundles, helping identify large modules that might benefit from further splitting.

Google Lighthouse is an excellent tool for auditing web page performance. A good target for the "First-load JS" budget for marketing-oriented pages is typically under 200KB (gzipped). For more complex web applications, this might be higher, but aiming to keep the initial payload as small as possible is always beneficial.

  • Webpack Bundle Analyzer: Install webpack-bundle-analyzer and add it to your Webpack config. It generates an interactive treemap visualization of the contents of your bundles.
  • Lighthouse: Run Lighthouse audits (available in Chrome DevTools under the "Lighthouse" tab) on your deployed application to get actionable insights on bundle size, TTI, and other performance metrics.
  • Network Tab (Browser DevTools): Inspect the "Network" tab to see which JavaScript files are loaded, their sizes, and when they are requested. Look for multiple smaller JS chunks being loaded on demand.

Common Pitfalls and Troubleshooting

  • Not wrapping with <Suspense>: Forgetting to wrap a React.lazy() component with <Suspense> will result in a runtime error ("A component suspended while responding to synchronous input...").
  • Named exports: React.lazy() only supports default exports. If your component is a named export, you'll need an intermediate module or re-export it as default:
    // MyComponent.js
            export const MyComponent = () => <div>Hello</div>;
    
            // Lazy-load with an intermediate module
            const LazyMyComponent = lazy(() =>
              import('./MyComponent').then(module => ({ default: module.MyComponent }))
            );
  • Too many small chunks: While splitting is good, creating excessively small chunks can lead to high network overhead due to many HTTP requests. Find a balance; route-level splitting is often a good starting point.
  • Server-Side Rendering (SSR) issues: React.lazy() and <Suspense> are primarily for client-side rendering. For SSR, you'll need solutions like Loadable Components which provide similar functionality but with SSR support to prevent flash-of-unstyled-content (FOUC) and ensure content is present for crawlers.
  • Incorrect base URL for chunks: If your application is deployed to a sub-path (e.g., https://example.com/myapp/), ensure your bundler's public path is configured correctly (e.g., output.publicPath in Webpack) so that dynamic imports resolve to the correct chunk URLs.

Back to the knowledge base · Ask the AI assistant