Большой стартовый bundle почти всегда бьет по реальному UX: медленнее первая загрузка, дольше инициализация на слабых устройствах и больше кода, который пользователь вообще может не открыть. Если главная страница тянет за собой админку, графики и редкие настройки, значит сборка разрезана плохо.

Code splitting решает именно эту проблему. Идея простая: загружать код не целиком, а по точкам входа. В React для этого обычно используют lazy и Suspense, а самый естественный split point почти всегда находится на уровне маршрута или тяжелого виджета.

Обновлено 17 марта 2026: примеры переписаны под современные lazy/Suspense, а устаревшие замечания про динамический импорт и роутинг заменены на актуальные.

lazy: как объявить ленивый компонент

lazy принимает функцию, которая возвращает динамический import(). React не загрузит этот модуль заранее: отдельный chunk подтянется только тогда, когда компонент действительно понадобится.

import { lazy } from "react";

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

export default function App() {
  return <TodoList />;
}

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

Suspense: что показывать во время загрузки

Suspense закрывает техническую дыру между моментом рендера и моментом, когда модуль уже приехал в браузер. В fallback кладут простой и честный UI: текст, skeleton, spinner или облегченный placeholder.

import { Suspense, lazy } from "react";

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

export default function App() {
  return (
    <Suspense fallback={<p>Загружаем список задач...</p>}>
      <TodoList />
    </Suspense>
  );
}

Практическое правило здесь простое: fallback должен быть локальным и недорогим. Не ставь один глобальный Suspense на все приложение, если у тебя могут грузиться независимые части интерфейса.

Error Boundary рядом с ленивой загрузкой

Если загрузка модуля сорвется из-за сетевой ошибки или битого chunk, пользователь не должен видеть пустой экран. Ошибку удобно перехватывать рядом с Suspense.

import React, { Component, Suspense, lazy } from "react";

const TodoList = lazy(() => import("./routes/TodoList"));
const NewTodo = lazy(() => import("./routes/NewTodo"));

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

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

  render() {
    if (this.state.hasError) {
      return <p>Не удалось загрузить модуль. Попробуй обновить страницу.</p>;
    }

    return this.props.children;
  }
}

export default function App() {
  return (
    <ErrorBoundary>
      <Suspense fallback={<p>Загружаем экран...</p>}>
        <section>
          <TodoList />
          <NewTodo />
        </section>
      </Suspense>
    </ErrorBoundary>
  );
}

Suspense отвечает за состояние загрузки, а ErrorBoundary за состояние ошибки. Это две разные задачи, и лучше не смешивать их в одном описании.

Разделение кода по маршрутам

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

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

const HomePage = lazy(() => import("./routes/HomePage"));
const SettingsPage = lazy(() => import("./routes/SettingsPage"));

export default function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<p>Загружаем страницу...</p>}>
        <Routes>
          <Route path="/" element={<HomePage />} />
          <Route path="/settings" element={<SettingsPage />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

Именно здесь code splitting обычно дает лучший результат: у каждой страницы свой chunk, и ты не платишь за все приложение на первом экране.

Как работать с именованным экспортом

lazy работает с default export. Если у тебя в модуле именованный экспорт, не обязательно заводить отдельный файл только ради обертки. Проще сразу вернуть объект с default из import().

import { lazy } from "react";

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

А сам модуль может выглядеть так:

// ManyComponents.jsx
export function MyComponent() {
  return <section>Главный компонент</section>;
}

export function MyUnusedComponent() {
  return <aside>Редко используемый компонент</aside>;
}

Это рабочий вариант, но он не превращает именованный экспорт в отдельный chunk автоматически. Если тебе важно разделение на уровне файла, держи тяжелые компоненты в отдельных модулях.

Практическое правило выбора

Используй lazy там, где модуль:

  • не нужен на первом экране;
  • тяжелый по коду или по зависимостям;
  • открывается по явному действию пользователя;
  • естественно отделяется по route или по крупному UI-блоку.

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