Олег Марков
Suspense в React - управление асинхронными данными и ленивой загрузкой
Введение
Компонент Suspense в React создавался как фундамент для работы с асинхронными операциями в интерфейсе. Его основная идея проста: вы описываете, что должно происходить, пока часть интерфейса "ждет" данных или кода, а React берет на себя управление этим ожиданием.
С помощью Suspense вы можете:
- показывать резервный интерфейс (fallback), пока компонент загружается или ждет данные;
- разбивать бандл на части и подгружать код лениво;
- постепенно отображать части страницы, когда данные становятся доступны;
- управлять асинхронностью и на клиенте, и на сервере (в React 18+).
Давайте разберем, как Suspense работает, на что он способен и какие ограничения у него есть.
Что такое Suspense и как он работает концептуально
Основная идея Suspense
Suspense — это компонент-обертка, который умеет "перехватывать" асинхронные операции внутри своего поддерева и показывать вместо них запасной интерфейс, пока операция не завершится.
Выглядит это так:
import { Suspense } from "react";
import { SomeLazyComponent } from "./SomeLazyComponent";
function App() {
return (
<Suspense fallback={<div>Загрузка...</div>}>
{/* Все, что внутри Suspense, может быть "приостановлено" */}
<SomeLazyComponent />
</Suspense>
);
}
Комментарии:
// Импортируем Suspense из React
import { Suspense } from "react";
// Импортируем компонент, который может загружаться лениво
import { SomeLazyComponent } from "./SomeLazyComponent";
function App() {
return (
// Suspense оборачивает часть дерева, которая может ждать данных или кода
<Suspense
// fallback - что показывать, пока идет ожидание
fallback={<div>Загрузка...</div>}
>
{/* Внутри могут быть компоненты, которые "бросят" промис */}
<SomeLazyComponent />
</Suspense>
);
}
Важно понять одну ключевую вещь: Suspense не "ждет" промисы напрямую в вашем коде. Вместо этого React-компоненты, которые используют Suspense, при попытке рендеринга могут "бросать" промис (throw Promise). React видит этот промис, понимает, что дерево пока нельзя отрисовать до конца, и показывает fallback.
Где Suspense реально работает сейчас
На данный момент (React 18+) Suspense официально поддерживается в двух основных сценариях:
- Ленивый импорт компонентов через React.lazy.
- Асинхронные данные в новом серверном рендеринге (React Server Components, streaming SSR и т.п., например в Next.js 13+ с App Router).
Suspense "для данных" полностью на клиенте (например, без серверного рендеринга и без кастомных абстракций) пока официально не завершен как публичный API, но в экосистеме есть библиотеки, которые используют ту же идею (React Query, Relay, SWR — через специальный режим).
Suspense и ленивый импорт компонентов
Базовая связка Suspense + React.lazy
Самый распространенный вариант использования Suspense — ленивая загрузка компонентов с помощью React.lazy. Смотрите, я покажу вам, как это работает.
import { Suspense, lazy } from "react";
// Здесь мы описываем ленивый импорт компонента
const UserProfile = lazy(() => import("./UserProfile"));
function App() {
return (
<div>
<h1>Главная страница</h1>
{/* Оборачиваем лениво загружаемый компонент в Suspense */}
<Suspense fallback={<div>Загрузка профиля...</div>}>
<UserProfile />
</Suspense>
</div>
);
}
Комментарии:
// Импортируем Suspense и функцию lazy из React
import { Suspense, lazy } from "react";
// Функция lazy принимает функцию, которая возвращает import()
// import() возвращает промис с модулем
const UserProfile = lazy(() => import("./UserProfile"));
function App() {
return (
<div>
<h1>Главная страница</h1>
{/* Пока модуль UserProfile не загрузится, показываем fallback */}
<Suspense fallback={<div>Загрузка профиля...</div>}>
{/* Как только промис из import() завершится,
React отрисует UserProfile вместо fallback */}
<UserProfile />
</Suspense>
</div>
);
}
Как это работает "под капотом"
- При первом рендере React.lazy возвращает специальный "заглушечный" компонент.
- При попытке его отрисовать React вызывает функцию загрузки (import).
- Пока промис не завершился, React "бросает" этот промис вверх по дереву.
- Suspense, который находится выше в дереве, "перехватывает" промис и показывает fallback.
- Когда промис завершается, React повторно рендерит поддерево и уже может отрисовать реальный компонент.
Несколько ленивых компонентов внутри одного Suspense
Вы можете обернуть в один Suspense сразу несколько ленивых компонентов. Тогда fallback будет показываться, пока не загрузятся все компоненты внутри.
const Header = lazy(() => import("./Header"));
const Sidebar = lazy(() => import("./Sidebar"));
const Content = lazy(() => import("./Content"));
function App() {
return (
<Suspense fallback={<div>Загрузка страницы...</div>}>
{/* Пока любой из этих компонентов не загрузился, будет fallback */}
<Header />
<Sidebar />
<Content />
</Suspense>
);
}
Если вы хотите более гибкое поведение (например, чтобы заголовок показывался сразу, а контент подгружался отдельно), лучше вложить несколько Suspense.
Вложенные Suspense — частичная загрузка
Давайте разберемся на примере более реальной страницы:
const Header = lazy(() => import("./Header"));
const Sidebar = lazy(() => import("./Sidebar"));
const Content = lazy(() => import("./Content"));
function App() {
return (
<div>
<Suspense fallback={<div>Загрузка шапки...</div>}>
<Header />
</Suspense>
<div style={{ display: "flex" }}>
{/* Сайдбар и контент грузим независимо */}
<Suspense fallback={<div>Загрузка меню...</div>}>
<Sidebar />
</Suspense>
<Suspense fallback={<div>Загрузка контента...</div>}>
<Content />
</Suspense>
</div>
</div>
);
}
Как видите, этот код выполняет независимую загрузку разных частей интерфейса. Пользователь может увидеть, например, шапку и сайдбар, даже если основной контент еще не загрузился.
Частые ошибки при использовании React.lazy и Suspense
Обратите внимание на несколько важных моментов:
React.lazy поддерживает только default-экспорт модуля.
Если ваш компонент экспортируется по имени, нужно либо изменить экспорт, либо обернуть импорт:// В модуле export function Page() { return <div>Page</div>; } // Ленивый импорт с именованным экспортом const Page = lazy(() => import("./Page").then((mod) => ({ default: mod.Page })) );- Нельзя использовать React.lazy вне компонента (функция lazy должна быть на верхнем уровне модуля, но вызывать ее можно так, как в примерах).
- Suspense обязательно должен находиться выше по дереву, чем лениво загружаемый компонент, иначе вы получите ошибку "A component suspended while rendering, but no fallback UI was specified".
Suspense для данных — концепция и практическое применение
Почему Suspense полезен для работы с данными
Изначально Suspense задумывался не только для ленивой загрузки кода, но и для асинхронных данных. Идея в том, чтобы сделать рендер "декларативным" даже при работе с сетью.
Вместо:
// Псевдокод без Suspense
function User() {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser().then(setUser);
}, []);
if (!user) {
return <div>Загрузка пользователя...</div>;
}
return <div>{user.name}</div>;
}
Хотелось бы писать:
// Псевдокод с Suspense-ресурсом
function User() {
const user = userResource.read(); // Может "бросить" промис
return <div>{user.name}</div>;
}
function App() {
return (
<Suspense fallback={<div>Загрузка пользователя...</div>}>
<User />
</Suspense>
);
}
Здесь ключевой момент — функция read может либо вернуть данные, либо "бросить" промис, либо бросить ошибку. React, увидев брошенный промис, переключится на fallback.
Пример простого "ресурса" для Suspense (экспериментальный подход)
Сразу важно: это не официальный стабильный API, а паттерн, который демонстрирует идею. Покажу вам, как это реализовано на практике.
// Создаем утилиту для обертывания промиса в "ресурс"
function createResource(promise) {
let status = "pending"; // pending, success, error
let result;
// Запускаем промис и отслеживаем результат
const suspender = promise
.then((data) => {
status = "success";
result = data;
})
.catch((error) => {
status = "error";
result = error;
});
return {
read() {
if (status === "pending") {
// Бросаем промис - Suspense перехватит его
throw suspender;
} else if (status === "error") {
// Бросаем ошибку - ее может перехватить ErrorBoundary
throw result;
} else if (status === "success") {
// Возвращаем данные - можно рендерить UI
return result;
}
},
};
}
// Пример функции загрузки данных
function fetchUser() {
// Здесь мы делаем обычный запрос за данными
return fetch("/api/user").then((res) => res.json());
}
// Создаем ресурс один раз (в реальном приложении это надо делать аккуратнее)
const userResource = createResource(fetchUser());
function User() {
// read либо вернет данные, либо "бросит" промис/ошибку
const user = userResource.read();
return <div>Пользователь - {user.name}</div>;
}
function App() {
return (
<Suspense fallback={<div>Загрузка пользователя...</div>}>
<User />
</Suspense>
);
}
Комментарии к важным моментам:
- createResource оборачивает промис и предоставляет метод read.
- read — центральная точка: он решает, что делать сейчас:
- бросить промис (сигнал Suspense о загрузке),
- бросить ошибку (обработает ErrorBoundary),
- вернуть данные.
- При "броске" промиса React приостанавливает рендер и показывает fallback.
Еще раз подчеркну: такой подход лучше применять только в контролируемых условиях (например, в демо, внутреннем проекте или с учетом рекомендаций конкретного фреймворка). В продакшене обычно используют либо возможности фреймворка (Next.js, Remix), либо библиотеки, которые сами интегрируются с Suspense.
Интеграция с ErrorBoundary
Когда вы работаете с асинхронными данными, часто нужно обрабатывать не только "ожидание", но и ошибки. Suspense хорошо сочетается с ErrorBoundary (компонентом-обработчиком ошибок).
Простейший пример:
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
// В state храним флаг и информацию об ошибке
this.state = { hasError: false, error: null };
}
// Этот метод вызывается, если во вложенных компонентах произошла ошибка
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
// Здесь можно логировать ошибку во внешний сервис
componentDidCatch(error, info) {
console.error("Ошибка в поддереве", error, info);
}
render() {
if (this.state.hasError) {
// Выводим резервный UI при ошибке
return <div>Произошла ошибка при загрузке данных</div>;
}
// Если ошибки нет, рендерим дочерние компоненты
return this.props.children;
}
}
function App() {
return (
<ErrorBoundary>
<Suspense fallback={<div>Загрузка...</div>}>
<User />
</Suspense>
</ErrorBoundary>
);
}
В связке Suspense + ErrorBoundary у вас получаются три состояния:
- Ожидание (loading) — управляется Suspense (fallback).
- Ошибка (error) — управляется ErrorBoundary (его UI).
- Успешный рендер — основной компонент.
Suspense и серверный рендеринг (React 18)
Общее представление
В React 18 Suspense стал ключевым элементом нового серверного рендеринга с потоковой отдачей HTML (streaming). Идея такая:
- сервер может начать отдавать HTML, даже если часть данных еще не готова;
- участки, зависящие от асинхронных данных, могут быть "обернуты" в Suspense;
- браузер сначала показывает fallback, а затем постепенно "встраивает" готовые части.
Это особенно хорошо видно в современных фреймворках, например Next.js 13+ с App Router.
Пример концепции с серверными компонентами
В Next.js 13+ вы можете использовать Suspense для разных частей страницы:
// app/page.js (пример в стиле Next.js 13+)
import { Suspense } from "react";
import UserInfo from "./UserInfo";
import UserPosts from "./UserPosts";
import UserSidebar from "./UserSidebar";
export default function Page() {
return (
<div>
<h1>Профиль пользователя</h1>
{/* Блок с основной информацией загружается отдельно */}
<Suspense fallback={<div>Загрузка информации о пользователе...</div>}>
<UserInfo />
</Suspense>
<div style={{ display: "flex" }}>
{/* Сайдбар может загружаться независимо */}
<Suspense fallback={<div>Загрузка сайдбара...</div>}>
<UserSidebar />
</Suspense>
{/* Посты могут приходить еще позже */}
<Suspense fallback={<div>Загрузка постов...</div>}>
<UserPosts />
</Suspense>
</div>
</div>
);
}
На сервере эти компоненты могут быть асинхронными, например:
// Пример серверного компонента в Next.js
export default async function UserInfo() {
// Здесь мы делаем запрос к базе/АПИ с помощью await
const user = await getUser(); // асинхронная функция
// React на сервере умеет "приостанавливать" рендер этого компонента,
// пока await не завершится, и использовать Suspense
return <div>Имя - {user.name}</div>;
}
На клиент уходит HTML, в котором пока находятся fallback-элементы. Когда сервер "догружает" данные, он отправляет дополнительные фрагменты HTML, которые браузер встраивает в текущую страницу, заменяя fallback.
Приоритеты и плавность отображения
Suspense в серверном рендеринге помогает:
- быстрее показать "скелет" страницы, не дожидаясь всех данных;
- отдавать важные части (например, заголовок, навигацию) раньше, чем тяжелые блоки.
Фреймворки могут дополнительно управлять порядком приоритета (например, помещая важные блоки выше).
Особенности поведения и нюансы использования Suspense
Поведение при повторном рендере
Если компонент внутри Suspense повторно "бросает" промис при рендере (например, при смене параметров запроса), то Suspense снова покажет fallback.
Важно продумать:
- хотите ли вы мигать между основным UI и состоянием загрузки;
- или вам нужно, например, использовать "transition", чтобы сохранить старые данные, пока приходят новые.
В React 18 вы можете использовать startTransition:
import { useState, startTransition } from "react";
function Search({ resourceFactory }) {
const [resource, setResource] = useState(() =>
resourceFactory("")
);
function handleChange(e) {
const value = e.target.value;
// Обновление, которое может вызвать Suspense, запускаем в transition
startTransition(() => {
setResource(resourceFactory(value));
});
}
const results = resource.read();
return (
<div>
<input onChange={handleChange} />
<ul>
{results.map((item) => (
<li key={item.id}>{item.title}</li>
))}
</ul>
</div>
);
}
Комментарии:
// startTransition помогает React отнести некоторые обновления к "менее срочным"
// Это уменьшает "мигание" интерфейса при частых Suspense-переходах
Fallback — это не "строгое" состояние загрузки
fallback в Suspense может мигать, если асинхронные операции короткие или часто повторяются. Поэтому иногда:
- делают "скелетон"-интерфейс, который выглядит более плавно;
- используют минимальные задержки перед показом fallback (в пользовательских обертках);
- комбинируют Suspense с локальными флагами загрузки, если нужно более тонкое поведение.
Локальный и глобальный Suspense
Вы можете создавать разные уровни Suspense:
- "глобальный" — вокруг всего приложения (например, на уровне маршрута);
- "локальный" — вокруг конкретного виджета или блока.
Подход простой:
- чем ближе Suspense к компоненту, тем более локальным будет fallback;
- чем выше Suspense в дереве, тем больше часть интерфейса он может "затемнить" во время загрузки.
Давайте посмотрим, что происходит в следующем примере:
function App() {
return (
<Suspense fallback={<div>Загрузка приложения...</div>}>
{/* Здесь может быть роутер, общие обертки и т.д. */}
<Layout />
</Suspense>
);
}
function Layout() {
return (
<div>
{/* Локальный Suspense для конкретного виджета */}
<Suspense fallback={<div>Загрузка виджета погоды...</div>}>
<WeatherWidget />
</Suspense>
{/* Другие блоки, которые не зависят от этого Suspense */}
<NewsList />
</div>
);
}
WeatherWidget может грузиться независимо, в то время как остальная часть Layout уже отображена.
Suspend и события
Suspense работает только во время рендеринга. Он не управляет событиями напрямую. Например, он не "останавливает" клик, пока идет загрузка. С этим нужно быть аккуратнее при интеграции с пользовательскими действиями.
Если вы вызываете что-то асинхронное по клику и хотите использовать Suspense, обычно вы:
- обновляете состояние, которое приводит к новому асинхронному запросу;
- этот запрос внутри "ресурса" снова приводит к "throw Promise";
- Suspense показывает fallback.
Все управление событиями по-прежнему остается за вами.
Практические паттерны использования Suspense
Паттерн "асинхронный маршрут"
Частый сценарий: вы хотите, чтобы каждая страница (route) приложения загружалась отдельно и имела собственный fallback.
Пример с React Router (v6+):
import { Suspense, lazy } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
// Лениво загружаем разные страницы
const HomePage = lazy(() => import("./pages/HomePage"));
const UserPage = lazy(() => import("./pages/UserPage"));
const SettingsPage = lazy(() => import("./pages/SettingsPage"));
function App() {
return (
<BrowserRouter>
{/* Один общий Suspense для всех маршрутов */}
<Suspense fallback={<div>Загрузка страницы...</div>}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/user" element={<UserPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
Здесь все страницы лениво загружаются, и пока они не подгружены — пользователь видит общий fallback.
Вы можете сделать и так:
function App() {
return (
<BrowserRouter>
<Routes>
<Route
path="/"
element={
<Suspense fallback={<div>Загрузка главной...</div>}>
<HomePage />
</Suspense>
}
/>
<Route
path="/user"
element={
<Suspense fallback={<div>Загрузка профиля...</div>}>
<UserPage />
</Suspense>
}
/>
</Routes>
</BrowserRouter>
);
}
В этом случае у каждой страницы свой fallback.
Паттерн "UI-скелет" вместо простого текста
Вместо текста "Загрузка..." часто хотят показывать "скелет" интерфейса — визуальную заглушку, похожую на конечный UI.
function UserSkeleton() {
return (
<div className="user-skeleton">
{/* Здесь мы рисуем "серые блоки" вместо реальных данных */}
<div className="avatar-placeholder" />
<div className="line-placeholder" />
<div className="line-placeholder short" />
</div>
);
}
function App() {
return (
<Suspense fallback={<UserSkeleton />}>
<User />
</Suspense>
);
}
Комментарии:
// UserSkeleton показывает "форму" будущего контента
// Такой подход делает интерфейс более "живым" во время ожидания данных или кода
Паттерн "группировка асинхронных блоков"
Вы можете группировать несколько асинхронных блоков под одним Suspense, если вам важно, чтобы они появлялись "одновременно". Например:
function Dashboard() {
return (
<Suspense fallback={<div>Загрузка дашборда...</div>}>
<UserStats />
<SalesChart />
<RecentActivity />
</Suspense>
);
}
В этом случае дашборд появится только после того, как загрузятся все три компонента.
Если нужно, чтобы, например, статистика показывалась первой, а активности — потом, вы можете вложить Suspense:
function Dashboard() {
return (
<div>
<Suspense fallback={<div>Загрузка статистики...</div>}>
<UserStats />
</Suspense>
<Suspense fallback={<div>Загрузка активности...</div>}>
<RecentActivity />
</Suspense>
</div>
);
}
Ограничения и подводные камни Suspense
Нельзя напрямую "await" в компоненте-клиенте
Если вы попытаетесь написать что-то вроде:
// Это не будет работать в обычном клиентском компоненте
async function User() {
const user = await fetchUser(); // Так делать нельзя в клиентском компоненте
return <div>{user.name}</div>;
}
React этого не поддерживает (кроме специальных случаев с серверными компонентами во фреймворках). Если вы хотите использовать Suspense на клиенте, вам нужно:
- либо использовать React.lazy (для кода),
- либо использовать абстракцию "ресурсов", как мы разобрали выше,
- либо использовать фреймворки/библиотеки, которые уже реализовали этот паттерн за вас.
Suspense не решает все проблемы асинхронности
Suspense:
- не отменяет запросы сам по себе;
- не управляет кешированием;
- не обрабатывает повторные попытки (retry);
- не заменяет полноценные data-fetching библиотеки.
Обычно его используют как "рендерный" слой над вашей логикой работы с данными, а не вместо нее.
Взаимодействие с concurrent features (React 18)
Suspense раскрывает свой потенциал в сочетании с возможностями "concurrent rendering" в React 18:
- startTransition;
- streaming SSR;
- selective hydration (выборочная "оживление" частей HTML на клиенте).
Но это означает, что:
- нужно аккуратно относиться к состояниям, которые могут вызывать частые "подвешивания";
- лучше избегать подвешивания во время критичных обновлений (например, при наборе текста, перетаскивании и т.п.).
Fallback может быть слишком "грубым"
Если вы поместите весь интерфейс под один Suspense, любое ожидание превратит экран в "Загрузка…". Лучше разбивать интерфейс на логические части:
- layout / navbar / footer — без Suspense или с отдельным;
- основная область контента — под другим Suspense;
- отдельные виджеты — под еще более локальными Suspense.
Так вы избежите "мерцания" всего приложения из-за одного относительно небольшого запроса.
Suspense — это механизм, который переносит управление асинхронностью на уровень рендера. Он сильно меняет подход к тому, как вы проектируете интерфейсы с данными и ленивой загрузкой кода. При грамотном использовании он позволяет:
- контролировать ожидание так же декларативно, как и остальной UI;
- сделать приложение отзывчивее за счет частичной загрузки;
- лучше интегрировать клиент и сервер в современных фреймворках.
Частозадаваемые технические вопросы по теме статьи и ответы на них
Как использовать Suspense вместе с React Query или SWR
Большинство библиотек по умолчанию не включают Suspense-режим, но часто имеют опцию. Например, в React Query:
useQuery(["user", id], fetchUser, {
suspense: true, // включаем поддержку Suspense
});
Инструкция:
- Включите флаг suspense в настройках хука.
- Оберните компонент, который использует этот хук, в Suspense.
- Fallback отвечать будет за состояние загрузки, а ошибки обрабатывайте через ErrorBoundary или onError.
Как сделать так, чтобы Suspense не показывал fallback при очень коротких запросах
Обычно это решается "оберткой" вокруг Suspense, которая задерживает показ fallback на несколько миллисекунд:
- Создайте компонент DelayedFallbackSuspense.
- Внутри храните локальное состояние shouldShowFallback с useState(false).
- С помощью setTimeout через useEffect включайте его, например, через 200 мс.
- В Suspense передавайте fallback либо реальный, либо null, в зависимости от shouldShowFallback.
Так вы избежите мерцаний при очень быстрых операциях.
Можно ли использовать Suspense для загрузки CSS или других ресурсов
Suspense официально заточен под React-компоненты и данные. Загрузка CSS обычно делается:
- через обычные link-теги;
- через бандлер (Webpack, Vite) и динамические импорты.
Теоретически можно обернуть загрузку ресурсов в "ресурс" и "бросать" промис, но это нестандартное решение. Лучше использовать встроенные механизмы бандлера и фреймворка.
Как тестировать компоненты, использующие Suspense
Инструкция:
- В тесте оборачивайте тестируемый компонент в Suspense с простым fallback.
- Если внутри используется React.lazy или Suspense-совместимые библиотеки, в тесте дождитесь завершения промисов:
- с помощью act и flushPromises (самодельной утилиты);
- или с помощью методов библиотеки тестирования (например, findByText в Testing Library).
- Для ErrorBoundary проверяйте, что при ошибке рендерится нужный fallback.
Как организовать кеширование данных при использовании собственного "ресурса" для Suspense
Мини-инструкция:
- Создайте глобальный Map или WeakMap для хранения кеша по ключам.
- Функция getResource(key, fetcher) должна:
- проверять, есть ли ресурс в кеше;
- если есть — возвращать его;
- если нет — создавать через createResource(fetcher()), класть в кеш, возвращать.
- При изменении входных параметров (например, id) генерируйте новый ключ и получайте новый ресурс.
- При необходимости реализуйте стратегию инвалидации кеша (например, по таймауту или по явному invalidate(key)).
Постройте личный план изучения Vue до уровня Middle — бесплатно!
Vue — часть карты развития Frontend
100+ шагов развития
30 бесплатных лекций
300 бонусных рублей на счет
Бесплатные лекции
Все гайды по Vue
Лучшие курсы по теме

Vue 3 и Pinia
Антон Ларичев
TypeScript с нуля
Антон Ларичев