Границы Suspense: проектирование того, что исчезает вместе
Граница Suspense решает, что исчезает вместе: ближайшая граница над приостановившимся компонентом показывает fallback. Вложенность задаёт порядок появления, fallback обязан резервировать место, иначе CLS, а startTransition удерживает показанный контент вместо спиннера.
Карточка товара в среднем e-commerce конвертировала нормально, пока команда персонализации не выкатила блок «с этим покупают». Блок ходил в ML-сервис ранжирования с p99 в 2,8 секунды. Вёрстку никто не трогал — новый компонент просто вставили в дерево. Наутро конверсия упала на 9%. Записи сессий показали почему: почти три секунды пользователи смотрели на полностраничный спиннер там, где раньше были название товара, цена, фото и кнопка «Купить». На странице была ровно одна граница Suspense — у корня роута, добавленная полтора года назад «чтобы было состояние загрузки». Новый блок приостановился, React пошёл вверх по дереву искать ближайшую границу, нашёл корневую и послушно заменил всю страницу на fallback. Данные были в порядке, компоненты в порядке, запрос в порядке. Единственным багом была геометрия: граница, поставленная там, где никто не подумал, что именно она заставит исчезнуть. Граница Suspense — это не обработка ошибок загрузки, это дизайн-решение о том, каким пикселям позволено пропадать вместе, и позиция в дереве — всё содержание этого решения.
Граница — дизайн-единица, а не сантехника
Когда компонент приостанавливается — читает неразрешённый promise через use, Suspense-совместимый фреймворковый запрос или React.lazy — React не ставит на паузу один этот компонент. Он идёт вверх по дереву до ближайшего предка <Suspense>, бросает рендер всего, что под этой границей, и показывает её fallback. Отсюда два следствия, и оба про расстановку, а не про данные:
- Всё под выбранной границей исчезает вместе. Соседи приостановившегося компонента, у которых данные уже были — название товара, кнопка покупки — пропадают тоже, потому что живут под той же границей. Гранулярность загрузочного UI — граница, а не компонент.
- Побеждает ближайшая граница. Оберните медленный блок в собственную границу — и скелетон покажет только его слот; оставьте голым — и приостановка уйдёт к любому предку с границей, как бы высоко он ни был, включая корень роута. Так запрос виджета превращается в полностраничный спиннер.
// ❌ одна граница на корне: ЛЮБОЙ приостановившийся компонент гасит всю страницу
<Suspense fallback={<FullPageSpinner />}>
<ProductHeader /> {/* fast */}
<BuyBox /> {/* fast */}
<RecommendationRail /> {/* p99 2.8s — takes everyone down */}
</Suspense>
// ✅ границы повторяют последовательность загрузки из дизайна
<ProductHeader />
<BuyBox />
<Suspense fallback={<RailSkeleton />}>
<RecommendationRail /> {/* slow — disappears alone */}
</Suspense>Правильный способ выбирать расстановку — задать вопрос, на который дизайнеры уже отвечают, рисуя скелетоны: какие области этой страницы загружаются как единое целое? Каждый ответ — граница. Граница на каждый компонент так же неверна, как одна на страницу: пятнадцать независимых спиннеров, всплывающих в случайном порядке, читаются как поломка, один гигантский спиннер — как тормоза. Документация react.dev здесь прямолинейна: не оборачивайте границей каждый компонент; последовательность загрузки должна совпадать с тем, что задумал дизайн.
Команда добавляет на дашборд медленный виджет аналитики. У роута одна граница Suspense — у корня. После деплоя открытие дашборда показывает полностраничный спиннер ~3 секунды, хотя данные каждого графика приходят за 200 мс. Что произошло?
Вложенность, порядок появления и троттлинг
Вложенные границы дают последовательность появления. Внешняя граница резолвится первой и открывает каркас лейаута; внутренние продолжают показывать свои скелетоны, пока не приедут их данные. Порядок строго сверху вниз — внутренняя граница не может открыться раньше родительской: пока родитель не открылся, внутреннего содержимого на экране нет вовсе. Так «сначала навигация, потом статья, потом комментарии» кодируется прямо в дереве.
Есть деталь, которую сениору стоит знать, потому что она всплывает в трейсах профайлера: React троттлит раскрытие вложенных границ. Когда несколько вложенных границ резолвятся подряд, React батчит появления, а не показывает каждое в момент готовности — интервал порядка 300 мс (константа в исходниках React, не настраивается). Мотивация — мерцание: без этого быстрое соединение даёт каскад скелетонов, каждый из которых виден 16 мс, и это читается как глюк. Цена тоже честная: при быстром попадании в кэш контент может появиться чуть позже, чем пришли данные. Если видите зазор ~300 мс между резолвом данных и отрисовкой внутри вложенного Suspense — это не ваш баг, это React меняет сырую латентность на визуальное спокойствие.
Fallback — это лейаут: резервируйте место
Fallback — не украшение: он занимает слот границы в раскладке. Спиннер в 40px на месте карточки в 480px означает два перелайаута страницы: когда отрисовался спиннер и когда его заменил контент. Это Cumulative Layout Shift (CLS — накопленный сдвиг раскладки), и полевой порог беспощаден — CLS выше 0,1 проваливает Core Web Vitals, и одна замена на всю высоту способна сжечь весь бюджет в одиночку. Дисциплина: каждый fallback — скелетон с теми же размерами, что у устоявшегося контента: та же высота, те же aspect-ratio-боксы для изображений, min-height на контейнерах с плавающей высотой. Относитесь к скелетону как к контракту с дизайн-системой, а не как к довеску: команды, генерирующие скелетоны из раскладки настоящего компонента (серые блоки в той же сетке), показывают CLS около нуля; команды с центрированным спиннером — 0,2 и больше.
Уточнение видимого: fallback, которого нужно избегать
Когда граница уже открылась, правила меняются. Если обновление — новый поисковый запрос, смена фильтра — снова приостанавливает уже видимый контент, поведение React по умолчанию жестокое: он прячет закоммиченный контент и опять показывает fallback. Список результатов моргает скелетоном на каждое нажатие. Именно это предотвращает startTransition (и useTransition): когда обновление, вызвавшее приостановку, помечено как transition, React не прячет видимый контент. Он держит старый UI на экране, рендерит новое дерево в фоне и подменяет, когда готово, — а isPending даёт затемнённый список на время ожидания.
const [isPending, startTransition] = useTransition();
function onQueryChange(q) {
setInput(q); // urgent: the input must echo now
startTransition(() => setQuery(q)); // non-urgent: results may suspend — keep old list
}Одна честная оговорка: transition защищает только уже показанный контент. Если новое дерево монтирует новенькую границу, которая приостанавливается, эта граница покажет свой fallback в любом случае — в её слоте нет ничего старого, что можно было бы удержать. Так что вопрос расстановки возвращается и здесь: границы, которые вы собираетесь уточнять на месте, должны оборачивать стабильные слоты, а не пересоздаваться на каждый запрос.
Страница поиска показывает результаты под границей Suspense. Каждое нажатие обновляет запрос, и вся панель результатов на 300 мс переключается в скелетон и обратно. Слой данных в порядке. Какое решение здесь сениорское?
Каталог режимов отказа
- Единственная корневая граница — каждый новый медленный компонент где угодно в роуте наследует полностраничный спиннер. Радиус поражения виджета — вся страница. Это инцидент из пролога, и он повторяется, потому что корневая граница «работает» месяцами, пока кто-то не добавит медленный лист.
- Граница на каждый компонент — попкорн-UI: дюжина скелетонов, резолвящихся в произвольном порядке, лейаут дёргается на каждом. Порядок появления должен быть спроектирован, а не эмерджентен.
- Спиннеры-фоллбеки неверных размеров — провалы CLS, видимые в полевых данных (CrUX), но не на быстрой машине разработчика: локально fallback живёт один кадр.
- Уточнение без transition — видимый контент пробивается в скелетон на каждом действии; пользователи читают это как «приложение теряет мои данные».
- 01Пройдите по шагам, что именно делает React, когда компонент приостанавливается, и объясните, как один медленный виджет гасит целую страницу.
- 02Что происходит, когда уже видимый контент снова приостанавливается на обновлении, и какие две оговорки про transitions и вложенные появления стоит помнить в профайлере?
Граница Suspense — это дизайн-решение, переодетое компонентом. Механизм невелик: приостановившийся потомок маршрутизируется к ближайшей границе над собой, и вся эта граница — виновник вместе с невиновными соседями — заменяется на fallback. Всё остальное следует из расстановки. Одна корневая граница делает каждый будущий медленный компонент полностраничным спиннером — так блок рекомендаций с p99 в 2,8 секунды уронил конверсию карточки товара; граница на каждый компонент даёт попкорн из скелетонов в произвольном порядке. Правильная гранулярность — области загрузки самого дизайна, закодированные вложенными границами, которые открываются строго сверху вниз, с батчингом вложенных появлений на троттлинге примерно в 300 мс против мерцания. Fallback — это лейаут: скелетон обязан занимать те же размеры, что и устоявшийся контент, иначе замена записывается в CLS против бюджета 0,1, по которому судят полевые данные. А когда контент уже на экране, дефолт превращается из полезного во враждебный: повторно приостанавливающее обновление опять прячет закоммиченный контент за fallback — если только оно не помечено как transition, и тогда React хранит старый UI, рендерит в фоне и выдаёт isPending для затемнения. Transition защищает только уже показанное; новенькие границы всё равно показывают fallback. Ставьте границы там, где дизайн хочет, чтобы области исчезали вместе, давайте каждому fallback след настоящего контента и уточняйте видимое только внутри transitions. Теперь, когда вы увидите полностраничный спиннер там, где должна была пропасть только одна секция, — вы знаете, где искать: найдите ближайшую границу Suspense над медленным компонентом и спросите, правильной ли области она касается или была поставлена когда-то давно и с тех пор не пересматривалась.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.