W tym artykule przyjrzymy się mechanizmom Reacta, takim jak React.lazy() i Suspense, oraz wykorzystaniu Intersection Observer do ładowania kodu na poziomie pojedynczych komponentów i całych podstron.
Słowami wstępu, lazy loading polega na opóźnieniu pobierania kodu do momentu, gdy użytkownik aplikacji faktycznie go potrzebuje. W połączeniu z code splittingiem, czyli podziałem kodu na mniejsze fragmenty, pozwala ograniczyć ilość JavaScriptu pobieranego podczas początkowego ładowania aplikacji.
W efekcie przeglądarka nie musi od razu pobierać całego kodu aplikacji, co może pozytywnie wpłynąć na czas jej początkowego ładowania.
React.lazy i Suspense
React udostępnia mechanizm React.lazy(), który pozwala opóźnić ładowanie komponentu do momentu jego pierwszego renderowania. W połączeniu z Suspense możemy dodatkowo wyświetlić zastępczy interfejs na czas pobierania kodu.
import { lazy, Suspense } from "react";
const HeavyComponent = lazy(() => import("./components/heavy-component"));
export const ExamplePage = () => {
return (
<Suspense fallback={<Skeleton />}>
<HeavyComponent />
</Suspense>
);
};
W powyższym przykładzie kod komponentu HeavyComponent zostanie pobrany dopiero wtedy, gdy React spróbuje go wyrenderować. Do tego momentu nie musi być częścią JavaScriptu pobieranego podczas początkowego ładowania aplikacji.
Suspense odpowiada za wyświetlenie fallback podczas oczekiwania na komponent. W jego obrębie możemy również obsłużyć wiele komponentów ładowanych przez React.lazy().
Podział kodu na poziomie komponentów
Lazy loading możemy zastosować również do pojedynczych komponentów, np. modala, który powinien zostać załadowany dopiero po wykonaniu określonej akcji przez użytkownika.
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>
);
}
W tym przypadku kod komponentu UserFormModal zostanie pobrany dopiero po kliknięciu przycisku Add user. Do tego momentu nie musi być częścią JavaScriptu ładowanego podczas wejścia na stronę.
Funkcja loadUserFormModal odpowiada za dynamiczny import komponentu. Wydzielamy ją osobno, ponieważ wykorzystamy ją również w kolejnym rozdziale, dotyczącym preloadingu.
Warto pamiętać, że React.lazy() oczekuje, że dynamiczny import zwróci moduł z właściwością default, zawierającą komponent React.
Jeśli komponent jest eksportowany jako named export, należy odpowiednio przekształcić wynik importu, mapując wskazany eksport na właściwość default.
const UserFormModal = lazy(() =>
import("./components/user-form-modal").then(({ UserFormModal }) => ({
default: UserFormModal,
}))
);
Wstępne ładowanie komponentów (Preloading)
Możemy przewidzieć, że użytkownik za chwilę będzie potrzebował danego komponentu i rozpocząć pobieranie jego kodu jeszcze przed wykonaniem akcji.
Przykładowo, gdy użytkownik najedzie kursorem na przycisk otwierający modal, możemy wcześniej załadować jego kod, aby ograniczyć czas oczekiwania po kliknięciu.
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 — rozpoczyna pobieranie kodu komponentu po najechaniu kursorem na przycisk.
onFocus — umożliwia wcześniejsze pobranie kodu również podczas nawigacji za pomocą klawiatury.
onClick — odpowiada za otwarcie modala.
Dzięki temu kod komponentu może zostać pobrany jeszcze przed kliknięciem przycisku. Jeśli pobieranie zakończy się odpowiednio wcześnie, modal będzie mógł wyświetlić się bez oczekiwania na dodatkowy import.
Warto jednak pamiętać, że preloading nie gwarantuje zakończenia pobierania przed kliknięciem. Dlatego nadal korzystamy z Suspense, który wyświetli fallback, jeśli komponent nie będzie jeszcze gotowy.
Preloading pozwala ograniczyć opóźnienie po kliknięciu, ale może też powodować pobieranie kodu, którego użytkownik ostatecznie nie wykorzysta.
Podział kodu na poziomie podstron
Lazy loading możemy również zastosować na poziomie całych podstron. W tym przypadku kod danej podstrony zostanie pobrany dopiero wtedy, gdy użytkownik przejdzie na odpowiadającą jej trasę.
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>
);
}
Dzięki temu podczas wejścia na / nie musimy od razu pobierać kodu podstrony /users. Zostanie on załadowany dopiero podczas przejścia na tę trasę, o ile nie został wcześniej pobrany lub preloadowany.
Suspense odpowiada za wyświetlenie PageSkeleton podczas oczekiwania na załadowanie komponentu podstrony.
Takie podejście pozwala ograniczyć ilość JavaScriptu pobieranego na początku, szczególnie w aplikacjach zawierających wiele rozbudowanych podstron.
Lazy loading z wykorzystaniem Intersection Observer
Jeśli chcemy opóźnić ładowanie komponentu do momentu, gdy użytkownik zbliży się do niego podczas przewijania strony, możemy wykorzystać Intersection Observer.
Mechanizm ten pozwala obserwować położenie elementu względem viewportu i rozpocząć ładowanie jego kodu, zanim pojawi się na ekranie.
Najpierw tworzymy custom hook 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 };
}
Następnie wykorzystujemy hook w komponencie, którego ładowanie chcemy opóźnić.
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 pozwala rozszerzyć obszar obserwacji poza granice viewportu. W naszym przypadku ustawienie 200px oznacza, że ładowanie komponentu może rozpocząć się już wtedy, gdy obserwowany element znajdzie się 200 px od widocznego obszaru strony.
Po spełnieniu warunku isVisible zmienia się na true, co powoduje renderowanie komponentu i rozpoczęcie dynamicznego importu.
Obserwator zostaje odłączony, ponieważ po pierwszym załadowaniu nie potrzebujemy ponownie sprawdzać widoczności elementu.
Animacja ładowania podstron
Gdy pobieranie kodu podstrony trwa dłużej, warto poinformować użytkownika o trwającym ładowaniu. Jednym ze sposobów może być animowany pasek umieszczony na górze layoutu aplikacji.
Zacznijmy od komponentu odpowiedzialnego za wyświetlanie paska ładowania.

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>
);
};
Aby uniknąć krótkiego pojawiania się i znikania paska podczas szybkich nawigacji, wykorzystamy bibliotekę spin-delay, którą opakujemy we własny hook useLoader.
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 });
};
Dzięki temu pasek pojawi się dopiero wtedy, gdy ładowanie potrwa dłużej niż 300 ms. Jeśli zostanie wyświetlony, pozostanie widoczny przez minimum 500 ms, co pozwoli uniknąć migotania interfejsu.
Następnie łączymy nasz komponent ze stanem nawigacji React Routera.
import { useNavigation } from "react-router";
import { LoadingBar } from "./loading-bar";
export const NavigationLoadingBar = () => {
const navigation = useNavigation();
return <LoadingBar isLoading={navigation.state === "loading"} />;
};
Hook useNavigation() udostępnia aktualny stan nawigacji. Gdy przyjmie wartość loading, możemy wyświetlić pasek informujący użytkownika o trwającym przejściu na kolejną podstronę.
Warto pamiętać, że ten stan może obejmować nie tylko ładowanie kodu trasy, ale również pobieranie danych przez loadery React Routera.
Na końcu wystarczy umieścić komponent w głównym layoucie aplikacji.
import { Outlet } from "react-router";
import { NavigationLoadingBar } from "./widgets/navigation-loading-bar";
export const RootLayout = () => {
return (
<>
<NavigationLoadingBar />
<Outlet />
</>
);
};
W ten sposób pasek ładowania będzie dostępny na poziomie całej aplikacji i będzie reagował na stan nawigacji obsługiwanej przez React Router.
Lazy loading tras i obsługa błędów
React Router udostępnia własny mechanizm lazy loadingu tras, który możemy wykorzystać podczas konfiguracji createBrowserRouter().
W przeciwieństwie do React.lazy(), gdzie odraczamy ładowanie komponentu, RouteObject.lazy pozwala dynamicznie ładować implementację trasy, w tym komponent oraz powiązane z nią mechanizmy React Routera.
Zacznijmy od zdefiniowania funkcji odpowiedzialnych za importowanie podstron.
const loadDashboardPage = lazyPage(() =>
import("./pages/dashboard").then(({ DashboardPage }) => DashboardPage),
);
const loadUsersListPage = lazyPage(() =>
import("./pages/users").then(({ UsersListPage }) => UsersListPage),
);
Następnie wykorzystujemy je w konfiguracji routera.
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,
},
]);
Funkcja lazyPage odpowiada za dynamiczny import komponentu i zwrócenie go w formacie oczekiwanym przez React Router.
Dodatkowo obsługujemy sytuację, w której pobranie chunka zakończy się błędem.
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 };
}
};
W przypadku poprawnego importu React Router otrzyma komponent podstrony. Jeśli import zakończy się błędem, zamiast niego zostanie wyrenderowany RouteChunkError.
Taka sytuacja może wystąpić na przykład po wdrożeniu nowej wersji aplikacji, gdy użytkownik nadal korzysta ze starszej wersji kodu, odwołującej się do chunka, który nie jest już dostępny na serwerze.
Wówczas możemy wyświetlić komunikat sugerujący odświeżenie przeglądarki.
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>
);
};
Odświeżenie strony może pomóc w pobraniu aktualnej wersji aplikacji i rozwiązaniu problemu z niedostępnym chunkiem.
Warto jednak pamiętać, że nieudany import nie zawsze wynika z wdrożenia nowej wersji. Przyczyną mogą być również błędy sieciowe lub chwilowa niedostępność serwera. Dlatego komunikat powinien informować o problemie z załadowaniem podstrony, zamiast jednoznacznie wskazywać jego przyczynę.
Weryfikacja rozmiaru chunków
Aby sprawdzić rozmiar pobieranego kodu JavaScript dla konkretnej podstrony lub komponentu, możemy wykorzystać zakładkę Network w DevTools.
Warto zweryfikować nie tylko wielkość plików, ale również moment ich pobierania. Dzięki temu sprawdzimy, czy zastosowany lazy loading faktycznie działa zgodnie z oczekiwaniami.
W tym celu:
- Uruchom produkcyjną wersję aplikacji.
- Otwórz DevTools → Network → JS.
- Zaznacz Disable cache.
- Odśwież stronę i sprawdź, jakie pliki JavaScript zostały pobrane podczas początkowego ładowania.
- Przejdź na inną podstronę lub wykonaj akcję, np. otwórz modal, i sprawdź, czy pojawiły się dodatkowe requesty.
W kolumnie Size możemy sprawdzić rozmiar przesyłanych zasobów. Pozwala to porównać początkowo pobierany JavaScript (initial JS) z fragmentami kodu ładowanymi dynamicznie (async chunks).
Jeśli chcemy dodatkowo przeanalizować zawartość poszczególnych chunków i sprawdzić, które zależności mają największy wpływ na ich rozmiar, możemy wykorzystać narzędzia takie jak vite-bundle-analyzer czy rollup-plugin-visualizer.
Warto pamiętać, że lazy loading nie zawsze oznacza mniejszy całkowity rozmiar aplikacji. Jego główną zaletą jest rozłożenie ładowania kodu w czasie, dlatego przed wdrożeniem takiej optymalizacji dobrze sprawdzić, czy rzeczywiście przynosi oczekiwane korzyści.
Powrót do artykułów