Suspense на клиенте: где границы ставить, чтобы не мигало
Suspense я первый раз попробовала ещё в эпоху pages router и осталась недовольной — половина случаев ломалась, скелетоны мигали невпопад. С приходом App Router и React 18+ я вернулась к нему серьёзно, и сейчас Suspense — основной инструмент, которым я делаю «изящную загрузку» в проде.
Расскажу, как я сейчас расставляю Suspense-границы на клиенте: где их хочется ставить, где не нужно, и какие частые ошибки приводят к мигающему UI.
Что Suspense даёт по сути
Suspense — это способ сказать React: «вот тут есть кусок UI, который может ещё не быть готов. Пока его нет — показывай fallback. Когда будет — покажи нормально».
<Suspense fallback={<Skeleton />}>
<UserProfile id={userId} />
</Suspense>
Дальше «может ещё не быть готов» зависит от того, кто бросает Promise. Это могут быть:
- Lazy-загруженные компоненты (
React.lazy). - TanStack Query с
useSuspenseQuery. - SWR в suspense-режиме.
- В RSC — серверные компоненты с
async.
Сама библиотека Suspense ничего не загружает. Она только обрабатывает «брошенный Promise» как «тут пока не готово».
Где ставить границы
Главное правило, которое я держу в голове: граница Suspense должна охватывать ровно тот кусок UI, который имеет смысл показывать вместе.
Если у тебя страница с шапкой, профилем юзера и его лентой постов — три отдельных раздела с тремя источниками данных — то вариантов несколько.
Один общий Suspense на всю страницу:
<Suspense fallback={<PageSkeleton />}>
<Header />
<UserProfile />
<UserFeed />
</Suspense>
Пользователь видит скелетон, пока самый медленный кусок не загрузится. Это плохой UX: даже если шапка и профиль готовы за 50 мс, лента грузится 800 мс — все три раздела до этого момента остаются скелетонами.
Три отдельные границы:
<Suspense fallback={<HeaderSkeleton />}><Header /></Suspense>
<Suspense fallback={<ProfileSkeleton />}><UserProfile /></Suspense>
<Suspense fallback={<FeedSkeleton />}><UserFeed /></Suspense>
Каждый блок появляется по мере готовности. Шапка — за 50 мс, профиль — за 100, лента — за 800. UX заметно живее.
Я выбираю количество границ исходя из того, можно ли показывать блоки независимо. Если каждый из них имеет смысл сам по себе — отдельная граница. Если они взаимосвязаны (например, заголовок и тело статьи — без заголовка тело не нужно) — одна общая.
Где границу ставить НЕ надо
Внутри элементов списка
Если у тебя список из 50 карточек, и каждая может тянуть свои данные, не оборачивай каждую карточку в свой Suspense:
// плохо
{products.map((p) => (
<Suspense fallback={<CardSkeleton />} key={p.id}>
<ProductCard product={p} />
</Suspense>
))}
Получишь 50 разных скелетонов, которые мигают вразнобой. UX — хаос. Лучше — один Suspense на весь список и общий fallback.
На очень мелком уровне
Suspense на уровне «один аватар» или «одна кнопка с текстом из API» обычно бессмысленен. Лучше показать заглушку или плейсхолдер прямо внутри компонента, без отдельной границы.
Где Suspense особенно полезен
1. Code splitting через React.lazy
Самый очевидный сценарий. Тяжёлый компонент (редактор, график, карта) подгружается лениво:
const Editor = React.lazy(() => import("./Editor"));
function Page() {
const [editing, setEditing] = useState(false);
return (
<>
<button onClick={() => setEditing(true)}>Редактировать</button>
{editing && (
<Suspense fallback={<EditorSkeleton />}>
<Editor />
</Suspense>
)}
</>
);
}
Пока чанк с редактором летит по сети — пользователь видит скелетон. Без Suspense ты бы держал в стейте флаг «загрузился ли чанк», обрабатывал ошибки руками. Тут всё работает само.
2. useSuspenseQuery в TanStack Query
Хук, который не возвращает data: undefined. Он просто бросает Promise, пока данные не приехали. Suspense ловит и показывает fallback. data в компоненте всегда определён — никаких if (!data) в каждом рендере.
function UserProfile({ id }: { id: string }) {
const { data } = useSuspenseQuery({
queryKey: ["user", id],
queryFn: () => api.getUser(id),
});
return <h1>{data.name}</h1>;
}
Это меняет стиль кода: компонент пишется так, как будто данные есть всегда. Ветвлений на «загружается / есть / ошибка» внутри компонента нет — они вынесены наружу через Suspense и Error Boundary.
3. Параллельная загрузка нескольких источников
Если на странице несколько useSuspenseQuery, обёрнутых в один Suspense, они грузятся параллельно. Suspense покажет fallback, пока все не закончат, и потом отрендерит готовый блок.
Это удобно, если данные из разных источников нужны вместе. Если один из источников медленный — выноси его в отдельную границу.
Частые ошибки
1. Граница слишком высоко
Suspense на корне страницы — и весь UI становится скелетоном на каждое изменение состояния, которое тригерит загрузку. Пользователь кликнул на фильтр — вместо подгрузки одного блока вся страница пропала.
Решение: ставь границу ровно вокруг того, что меняется. Фильтр поменял список — оборачивай Suspense вокруг списка, а не вокруг страницы.
2. Граница не вокруг того, что должно
<Suspense fallback={<Skeleton />}>
<Header />
</Suspense>
<UserFeed /> // useSuspenseQuery внутри, без Suspense выше
Если ниже UserFeed нет границы Suspense, его Promise полетит ещё выше — до ближайшей границы или до корня дерева. Иногда это работает, иногда — нет, а в App Router в RSC всё чаще ловишь рантайм-ошибку «no Suspense boundary». Проверяй: каждый источник, который кидает Promise, должен иметь над собой Suspense.
3. Скелетон слишком отличается от реального UI
Если скелетон и реальный контент разной высоты, при появлении контента страница прыгает. Скелетон должен быть похож по размерам на то, что появится — иначе CLS-метрика расстроится.
Я обычно делаю скелетоны через ту же сетку, что и реальный компонент, и закладываю в них точные размеры карточек/строк.
4. Suspense + transition: разный fallback
В React 18 у Suspense есть тонкий момент с useTransition. Если ты используешь startTransition, Suspense внутри транзакций не показывает fallback на «небольшие» обновления, оставляя старый UI на экране, пока новые данные не приедут.
const [isPending, startTransition] = useTransition();
const onChange = (value: string) => {
startTransition(() => {
setQuery(value);
});
};
Это поведение нужно для случаев, когда ты не хочешь, чтобы фильтр сбрасывал UI в скелетон. Но если ты этого не понимаешь — кажется, что Suspense «не работает». Не работает он намеренно, и это правильное поведение.
Сочетание с Error Boundary
Suspense обрабатывает «загружается». Ошибки — отдельная история, для них нужен Error Boundary. Я пишу их парой:
<ErrorBoundary fallback={<ErrorState />}>
<Suspense fallback={<Skeleton />}>
<UserProfile />
</Suspense>
</ErrorBoundary>
Если запрос упадёт — поймает ErrorBoundary. Если только медленно — Suspense. Эти две границы обычно ходят вместе на каждый «асинхронный» блок UI.
Что запомнить
Suspense — это инструмент про «как показывать промежуточное состояние, не плодя if (loading)». Ставь границы ровно вокруг тех кусков UI, которые имеют смысл показывать самостоятельно. Не оборачивай каждую карточку в списке отдельно — получишь хаос мерцающих скелетонов. Не оборачивай всю страницу одним Suspense — получишь блокировку на самом медленном куске.
В паре с Error Boundary это получается чистая картина: у каждого асинхронного блока есть свои «загружается» и «упало», описанные явно. Внутри компонента ты пишешь код так, будто данные есть всегда.