Menu

Suspense и lazy в React: загрузка и разделение кода

Suspense показывает запасной вариант, пока компоненты внутри не готовы, а React.lazy загружает код компонента только при его первом рендере. Разделение кода, вложенные границы, Suspense с use() в React 19 и обработка ошибок.

На этой странице есть исполняемые редакторы: меняйте, запускайте и сразу видите результат.

Suspense показывает запасной вариант, например сообщение о загрузке, пока компоненты внутри него ещё не готовы. Чаще всего ждут React.lazy: он загружает код компонента только при его первом рендере, и начальная загрузка остаётся небольшой.

Нажмите Show chart: на секунду появится Loading chart..., затем график. Скройте его и покажите снова, и он появится сразу без новой строки в логе, потому что lazy хранит загруженный модуль.

Разделение кода через lazy

Редактор держит всё в одном файле, поэтому пример собирает медленный модуль вручную: промис, который через секунду разрешается в объект с экспортом default. В настоящем приложении компонент лежит в отдельном файле, и вы передаёте динамический импорт:

import { lazy, Suspense } from 'react';

const Chart = lazy(() => import('./Chart.jsx'));

export default function Dashboard() {
    return (
        <Suspense fallback={<p>Loading chart...</p>}>
            <Chart />
        </Suspense>
    );
}

import('./Chart.jsx') возвращает промис модуля. Бандлеры вроде Vite и webpack видят динамический импорт и помещают Chart.jsx и всё, что использует только он, в отдельный файл, который скачивается при первом рендере <Chart />. Правила, которые стоит знать:

  • Модулю нужен экспорт по умолчанию. lazy читает свойство default того, во что разрешается промис. Для именованного экспорта сопоставьте его: lazy(() => import('./charts.js').then((m) => ({ default: m.LineChart }))).
  • Вызывайте lazy на верхнем уровне модуля. Внутри компонента он создавал бы новый тип компонента при каждом рендере, поэтому React размонтировал бы старый, потерял бы его состояние и загрузил бы его снова.
  • Разделяйте там, где пользователь всё равно ждёт. Маршруты, модальные окна, редко открываемые панели и тяжёлые виджеты (редакторы, графики, карты) хорошие кандидаты. Разделение каждого маленького компонента добавляет запросы и состояния загрузки без всякой выгоды.

Предзагрузка до клика

Ленивый компонент начинает загружаться при первом рендере, поэтому после клика пользователь всегда ждёт хотя бы одну загрузку. Если можно угадать, что клик будет, начните загрузку раньше. Сохраните функцию импорта в переменной и вызывайте её при наведении или фокусе; браузер сохраняет модуль, поэтому когда lazy позже вызовет тот же импорт, он разрешится без второй загрузки.

const loadChart = () => import('./Chart.jsx');
const Chart = lazy(loadChart);

<button onMouseEnter={loadChart} onFocus={loadChart} onClick={() => setShow(true)}>
    Show chart
</button>

Как Suspense решает, что показать

Когда компонент внутри <Suspense> не готов, он приостанавливается: React прекращает рендер этой части и показывает fallback ближайшего Suspense над ним. Запасным вариантом заменяется всё внутри этой границы, а не только ожидающий компонент. Когда то, чего он ждал, готово, React снова рендерит содержимое и заменяет запасной вариант.

Поэтому положение границы это решение дизайна. Помещайте независимые части в собственные границы, чтобы каждая могла появиться, когда будет готова:

Сначала вся страница показывает Loading page..., потому что Header принадлежит внешней границе. Когда шапка готова, появляется статья с Loading comments... под ней, а комментарии приходят последними. Замените 1500 на 3000, и превью перезагрузится с более долгим ожиданием только для комментариев. Удалите внутренний <Suspense> (оставив <Comments />), и страница будет ждать комментариев, прежде чем показать что-либо.

Suspense с use() в React 19

В React 19 компонент может прочитать промис через use(promise). Если промис ещё ожидает, компонент приостанавливается, и ближайший Suspense показывает свой запасной вариант; когда промис выполнится, use вернёт значение. Полностью это разбирает страница о хуке use. Поддельная fetchUser ниже заменяет настоящий запрос.

Пройдитесь по пользователям, затем вернитесь к User 1: он появится мгновенно, а Console не выведет новой загрузки, потому что промис уже в кэше.

Почему промис нужно кэшировать

Словарь cache здесь не оптимизация, он обязателен. Компонент, который приостанавливается, ничего не сохраняет от этой попытки: когда промис выполнится, React снова рендерит его с самого начала. Если бы Profile вызывал fetchUser(id) напрямую, каждая попытка создавала бы новый промис, запускала бы новый запрос и снова на нём приостанавливалась. Профиль никогда не появится, а Console заполнится строками fetching user. Поэтому промис должен приходить откуда-то, что переживает рендер:

  • из кэша по ключу запроса, как словарь выше (библиотеки данных вроде TanStack Query и загрузчики фреймворков делают это за вас);
  • от родителя, который создаёт промис один раз, в обработчике события или в серверном компоненте, и передаёт его вниз пропсом.

Обратите внимание, что каждый новый пользователь всё равно заменяет профиль запасным вариантом. Если вы хотите держать старого пользователя на экране, пока следующий не будет готов, оберните обновление в переход: startTransition(() => setId(n)). При переходе React не скрывает уже видимое содержимое (см. useTransition).

Ошибкам нужна граница ошибок

Suspense отвечает за ожидание, а не за сбой. Если ленивый импорт не удаётся (пользователь ушёл в офлайн, новый деплой удалил старый чанк) или промис, переданный в use, отклоняется, React бросает ошибку в ближайшую границу ошибок. Без неё всё дерево под корнем размонтируется. Границы ошибок по-прежнему классовые компоненты (см. границы ошибок):

<ErrorBoundary fallback={<p>Could not load the chart.</p>}>
    <Suspense fallback={<p>Loading chart...</p>}>
        <Chart />
    </Suspense>
</ErrorBoundary>

Чего Suspense не замечает

Suspense реагирует только на компоненты, которые приостанавливаются: компоненты lazy, use(promise) и источники данных, созданные для Suspense (загрузчики фреймворков, библиотеки с поддержкой Suspense). Fetch внутри useEffect, который устанавливает состояние по завершении, не приостанавливает компонент, поэтому граница Suspense вокруг него никогда не покажет запасной вариант. Для такого шаблона вы держите собственное состояние loading, как показано на странице о загрузке данных.

Часто задаваемые вопросы

Что такое Suspense в React?

<Suspense fallback={...}> это компонент, который показывает свой fallback, пока какой-нибудь компонент внутри чего-то ждёт, например лениво загружаемого кода или данных, прочитанных через use. Когда всё внутри готово, React заменяет запасной вариант содержимым.

Что делает React.lazy?

lazy(() => import('./Chart.jsx')) создаёт компонент, код которого скачивается при его первом рендере. Бандлеры кладут этот файл в отдельный чанк, поэтому начальная страница загружает меньше JavaScript.

Работает ли Suspense для загрузки данных?

Да, когда источник данных это поддерживает. В React 19 компонент может прочитать промис через use(promise) и приостановиться, пока тот не выполнится. Фреймворки вроде Next.js тоже интегрируют Suspense со своей загрузкой данных. Fetch внутри useEffect Suspense не запускает.

Как обрабатывать ошибки с Suspense?

Suspense отвечает только за ожидание. Если ленивый импорт или промис завершается неудачей, ошибка уходит в ближайшую границу ошибок, поэтому оберните в неё границу Suspense (или её родителя).

Где вызывать lazy?

На верхнем уровне модуля, вне любого компонента. Вызов lazy внутри компонента создаёт новый тип компонента при каждом рендере, что сбрасывает его состояние и загружает его заново.

Иллюстрация языков программирования Coddy

Учитесь программировать с Coddy

НАЧАТЬ