PL EN

Lazy Loading Components and Routes in React

In this article, we'll explore React features such as React.lazy() and Suspense, as well as how to use Intersection Observer to load code at both the component and route levels.

To start with, lazy loading means delaying code loading until the user actually needs it. Combined with code splitting, which divides code into smaller chunks, it helps reduce the amount of JavaScript downloaded during the initial application load.

As a result, the browser doesn't have to download all the application's code upfront, which can improve initial loading performance.

React.lazy and Suspense

React provides React.lazy(), which allows us to defer loading a component until it's rendered for the first time. Combined with Suspense, we can also display a fallback UI while the component's code is being loaded.

import { lazy, Suspense } from "react";

const HeavyComponent = lazy(() => import("./components/heavy-component"));

export const ExamplePage = () => {
  return (
    <Suspense fallback={<Skeleton />}>
      <HeavyComponent />
    </Suspense>
  );
};

In the example above, the HeavyComponent code will only be downloaded when React attempts to render it. Until then, it doesn't have to be part of the JavaScript downloaded during the initial application load.

Suspense is responsible for displaying the fallback while waiting for the component to load. We can also use a single Suspense boundary to handle multiple components loaded with React.lazy().

Component-Level Code Splitting

We can also apply lazy loading to individual components, such as a modal that should only be loaded after a specific user interaction.

const loadUserFormModal = () => import("./components/user-form-modal");

const UserFormModal = lazy(loadUserFormModal);

function UsersPage() {
  const [isUserModalFormOpen, setIsUserModalFormOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setIsUserModalFormOpen(true)}>Add user</button>

      {isUserModalFormOpen && (
        <Suspense fallback={<ModalSkeleton />}>
          <UserFormModal onClose={() => setIsUserModalFormOpen(false)} />
        </Suspense>
      )}
    </div>
  );
}

In this case, the UserFormModal component's code will only be downloaded after clicking the Add user button. Until then, it doesn't have to be included in the JavaScript loaded when the user visits the page.

The loadUserFormModal function handles the dynamic import of the component. We've extracted it into a separate function because we'll also use it in the next section, which covers preloading.

It's worth remembering that React.lazy() expects the dynamic import to return a module with a default property containing a React component.

If the component uses a named export, we need to transform the import result by mapping the named export to the default property.

const UserFormModal = lazy(() =>
  import("./components/user-form-modal").then(({ UserFormModal }) => ({
    default: UserFormModal,
  }))
);

Preloading Components

We can anticipate that a user might need a particular component soon and start downloading its code before they perform an action.

For example, when a user hovers over a button that opens a modal, we can preload its code to reduce the waiting time after the click.

const loadUserFormModal = () => import("./components/user-form-modal");

const UserFormModal = lazy(loadUserFormModal);

function UsersPage() {
  const [isUserModalFormOpen, setIsUserModalFormOpen] = useState(false);

  return (
    <div>
      <button
        onMouseEnter={() => void loadUserFormModal()}
        onFocus={() => void loadUserFormModal()}
        onClick={() => setIsUserModalFormOpen(true)}
      >
        Add user
      </button>

      {isUserModalFormOpen && (
        <Suspense fallback={<ModalSkeleton />}>
          <UserFormModal onClose={() => setIsUserModalFormOpen(false)} />
        </Suspense>
      )}
    </div>
  );
}

onMouseEnter — starts downloading the component's code when the user hovers over the button.

onFocus — allows the code to be preloaded during keyboard navigation as well.

onClick — handles opening the modal.

This way, the component's code can be downloaded before the user clicks the button. If the download finishes early enough, the modal can appear without waiting for an additional import.

However, preloading doesn't guarantee that the download will finish before the click. That's why we still use Suspense to display a fallback if the component isn't ready yet.

Preloading can reduce the delay after a click, but it may also result in downloading code that the user never actually needs.

Route-Level Code Splitting

We can also apply lazy loading to entire pages. In this case, a page's code is downloaded only when the user navigates to its corresponding route.

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

const DashboardPage = lazy(() => import("./pages/dashboard"));
const UsersPage = lazy(() => import("./pages/users"));

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<PageSkeleton />}>
        <Routes>
          <Route path="/" element={<DashboardPage />} />
          <Route path="/users" element={<UsersPage />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

This means that when a user visits /, we don't have to download the code for the /users page immediately. It will be loaded when the user navigates to that route, unless it has already been downloaded or preloaded.

Suspense is responsible for displaying PageSkeleton while waiting for the page component to load.

This approach helps reduce the amount of JavaScript downloaded upfront, especially in applications with many complex pages.

Lazy Loading with Intersection Observer

If we want to delay loading a component until the user gets close to it while scrolling, we can use Intersection Observer.

This API allows us to observe an element's position relative to the viewport and start loading its code before it becomes visible on the screen.

First, let's create a custom hook called useLazyComponentObserver.

import { useEffect, useRef, useState, type RefObject } from "react";

const DEFAULT_ROOT_MARGIN = "200px";

type UseLazyComponentObserverProps = {
  rootMargin?: string;
};

type UseLazyComponentObserverReturn<T extends HTMLElement> = {
  ref: RefObject<T | null>;
  isVisible: boolean;
};

export const useLazyComponentObserver = <
  T extends HTMLElement = HTMLDivElement,
>({
  rootMargin = DEFAULT_ROOT_MARGIN,
}: UseLazyComponentObserverProps = {}): UseLazyComponentObserverReturn<T> => {
  const ref = useRef<T>(null);
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    const element = ref.current;

    if (!element || isVisible) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsVisible(true);
          observer.disconnect();
        }
      },
      { rootMargin }
    );

    observer.observe(element);

    return () => observer.disconnect();
  }, [rootMargin, isVisible]);

  return { ref, isVisible };
}

Next, we use the hook in the component where we want to delay loading.

import { lazy, Suspense } from "react";
import { useLazyComponentObserver } from "./use-lazy-component-observer";

const UsersList = lazy(() => import("./components/users-list"));

function UsersPage() {
  const { ref, isVisible } = useLazyComponentObserver();

  return (
    <div ref={ref}>
      {isVisible ? (
        <Suspense fallback={<UsersListSkeleton />}>
          <UsersList />
        </Suspense>
      ) : (
        <UsersListSkeleton />
      )}
    </div>
  );
}

rootMargin allows us to extend the observation area beyond the viewport boundaries. In our example, setting it to 200px means that component loading can begin when the observed element is still 200 pixels away from the visible area of the page.

Once the condition is met, isVisible changes to true, which triggers component rendering and starts the dynamic import.

The observer is then disconnected because, once loading has been triggered, we no longer need to keep checking the element's visibility.

Loading Animation for Lazy-Loaded Pages

When downloading a page's code takes longer, it's worth letting the user know that loading is in progress. One way to do this is by displaying an animated loading bar at the top of the application layout.

Let's start with a component responsible for displaying the loading bar.

Loading bar animation

import { useTranslation } from "react-i18next";
import { useLoader } from "./hooks/use-loader";

type LoadingBarProps = {
  isLoading: boolean;
};

export const LoadingBar = ({ isLoading }: LoadingBarProps) => {
  const { t } = useTranslation();
  const visible = useLoader({ isLoading });

  if (!visible) {
    return null;
  }

  return (
    <div
      aria-label={t("common.loading", { ns: "common" })}
      aria-live="polite"
      className="pointer-events-none fixed inset-x-0 top-0 z-50 h-2 overflow-hidden"
      role="progressbar"
    >
      <div className="h-full w-1/3 bg-primary motion-safe:animate-route-loading motion-reduce:w-full" />
    </div>
  );
};

To prevent the loading bar from briefly appearing and disappearing during fast navigations, we'll use the spin-delay library and wrap it in our own useLoader hook.

import { useSpinDelay } from "spin-delay";

export const LOADER_DELAY = 300;
export const LOADER_MIN_DURATION = 500;

type UseLoaderProps = {
  isLoading: boolean;
  delay?: number;
  minDuration?: number;
};

export const useLoader = ({
  isLoading,
  delay = LOADER_DELAY,
  minDuration = LOADER_MIN_DURATION,
}: UseLoaderProps) => {
  return useSpinDelay(isLoading, { delay, minDuration });
};

With this setup, the loading bar will only appear if loading takes longer than 300 ms. Once displayed, it will remain visible for at least 500 ms, helping prevent UI flickering.

Next, we connect our component to React Router's navigation state.

import { useNavigation } from "react-router";
import { LoadingBar } from "./loading-bar";

export const NavigationLoadingBar = () => {
  const navigation = useNavigation();

  return <LoadingBar isLoading={navigation.state === "loading"} />;
};

The useNavigation() hook provides information about the current navigation state. When it changes to loading, we can display a loading bar to inform the user that navigation to another page is in progress.

It's worth remembering that this state can include not only route code loading but also data fetching through React Router loaders.

Finally, we add the component to the application's root layout.

import { Outlet } from "react-router";
import { NavigationLoadingBar } from "./widgets/navigation-loading-bar";

export const RootLayout = () => {
  return (
    <>
      <NavigationLoadingBar />
      <Outlet />
    </>
  );
};

This way, the loading bar is available throughout the application and responds to navigation state managed by React Router.

Lazy Loading Routes and Error Handling

React Router provides its own route-level lazy loading mechanism, which we can use when configuring createBrowserRouter().

Unlike React.lazy(), which defers loading a component, RouteObject.lazy allows us to dynamically load a route's implementation, including its component and related React Router features.

Let's start by defining the functions responsible for importing our pages.

const loadDashboardPage = lazyPage(() =>
  import("./pages/dashboard").then(({ DashboardPage }) => DashboardPage),
);

const loadUsersListPage = lazyPage(() =>
  import("./pages/users").then(({ UsersListPage }) => UsersListPage),
);

Next, we use these functions in our router configuration.

const routes: RouteObject[] = [
  {
    element: <DashboardLayout />,
    children: [
      {
        element: <Outlet />,
        errorElement: <ErrorPage />,
        children: [
          { path: APP_ROUTES.dashboard, lazy: loadDashboardPage },
          { path: APP_ROUTES.users.list, lazy: loadUsersListPage },
        ],
      },
    ],
  },
];

export const router = createBrowserRouter([
  {
    element: <RootLayout />,
    HydrateFallback: PageLoader,
    children: routes,
  },
]);

The lazyPage function handles the dynamic import of a component and returns it in the format expected by React Router.

We also handle situations where loading a chunk fails.

import type { ComponentType } from "react";
import { RouteChunkError } from "./route-chunk-error";

export const lazyPage = (load: () => Promise<ComponentType>) => async () => {
  try {
    return { Component: await load() };
  } catch {
    return { Component: RouteChunkError };
  }
};

If the import succeeds, React Router receives the page component. If it fails, RouteChunkError will be rendered instead.

This can happen, for example, after deploying a new version of the application while the user is still running an older version of the code that references a chunk no longer available on the server.

In this situation, we can display a message suggesting that the user refresh the browser.

import { useTranslation } from "react-i18next";
import { Button, ErrorState } from "./components";

export const RouteChunkError = () => {
  const { t } = useTranslation();

  return (
    <div className="flex flex-col items-center justify-center gap-4 px-4 py-16">
      <ErrorState
        title={t("route-chunk-error.title")}
        description={t("route-chunk-error.description")}
      />
      <Button onClick={() => window.location.reload()}>
        {t("action.refresh")}
      </Button>
    </div>
  );
};

Refreshing the page may help download the latest version of the application and resolve issues caused by an unavailable chunk.

However, a failed import doesn't always mean that a new application version has been deployed. It can also result from network errors or temporary server unavailability. That's why the message should inform the user that the page couldn't be loaded rather than assume a specific cause.

Verifying Chunk Sizes

To check the size of the JavaScript downloaded for a specific page or component, we can use the Network tab in DevTools.

It's worth checking not only the size of the files but also when they are downloaded. This allows us to verify whether our lazy loading implementation actually works as expected.

To do this:

  1. Run the production build of the application.
  2. Open DevTools → Network → JS.
  3. Enable Disable cache.
  4. Refresh the page and check which JavaScript files are downloaded during the initial load.
  5. Navigate to another page or perform an action, such as opening a modal, and check whether additional requests appear.

In the Size column, we can check the size of the transferred resources. This allows us to compare JavaScript downloaded during the initial load (initial JS) with code loaded dynamically (async chunks).

If we also want to analyze the contents of individual chunks and identify which dependencies contribute the most to their size, we can use tools such as vite-bundle-analyzer or rollup-plugin-visualizer.

It's worth remembering that lazy loading doesn't always reduce the total size of an application. Its main benefit is spreading code loading over time, so before implementing this optimization, it's a good idea to verify whether it actually delivers the expected improvements.

Go back to articles