Большой стартовый bundle почти всегда бьет по реальному UX: медленнее первая загрузка, дольше инициализация на слабых устройствах и больше кода, который пользователь вообще может не открыть. Если главная страница тянет за собой админку, графики и редкие настройки, значит сборка разрезана плохо.
Code splitting решает именно эту проблему. Идея простая: загружать код не целиком, а по точкам входа. В React для этого обычно используют lazy и Suspense, а самый естественный split point почти всегда находится на уровне маршрута или тяжелого виджета.
lazy: как объявить ленивый компонентSuspense: что показывать во время загрузки- Error Boundary рядом с ленивой загрузкой
- Разделение кода по маршрутам
- Как работать с именованным экспортом
- Практическое правило выбора
Обновлено 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 каждый маленький компонент, выигрыш быстро исчезнет, а сетевых запросов и состояний загрузки станет больше.