Размещение Suspense
Suspense показывает fallback, пока грузится потомок; границы рядом с местом, где нужны данные, дают уже загруженному контенту проявляться постепенно, тогда как одна корневая граница гасит всю страницу, пока ждёт любая мелкая часть.
Ты знаешь, что <Suspense> делает: показывает fallback, пока потомок ещё грузится, а затем подставляет настоящий контент. Поэтому ты оборачиваешь страницу в один Suspense, кладёшь спиннер в fallback и отгружаешь. Работает. Страница грузится.
Потом кто-то замечает: профиль пользователя, боковая навигация и статичная обвязка страницы готовы за 80 мс — но вся страница висит на спиннере 1,2 секунды, потому что один медленный виджет аналитики ещё не разрешился. Ничего не сломано. Неправильно размещение. То, где ты проводишь границу, — не деталь, а UX-решение, определяющее, проявляется страница постепенно или гаснет единым блоком.
После этого урока ты можешь объяснить, что Suspense раскрывает контент с гранулярностью своих границ; размещать границы рядом с местом, где нужна каждая часть данных, чтобы быстрый контент отрисовывался сразу, а медленный подтекал потоком за собственным fallback; распознавать режим отказа единственного Suspense на верхнем уровне (гашение по принципу «всё или ничего»); и решать, насколько дробить, спрашивая, у каких частей страницы независимые тайминги загрузки, которые пользователю стоит видеть разрешающимися по отдельности.
Suspense раскрывает контент с гранулярностью своей границы — граница и есть единица «появляется вместе». Всё внутри одного <Suspense> трактуется как один атомарный кусок: React показывает fallback, пока не разрешится каждый приостанавливающийся потомок под ним, а затем раскрывает их все сразу. Значит, граница — это не просто «где находится спиннер»: она задаёт, какой срез UI живёт и умирает вместе.
// Всё под этой ОДНОЙ границей появляется вместе.
// Если <SlowFeed> занимает 1.2с, <FastHeader> и <FastSidebar>
// тоже спрятаны за fallback все 1.2с.
<Suspense fallback={<PageSkeleton />}>
<FastHeader />
<FastSidebar />
<SlowFeed />
</Suspense>Урок всего этого юнита вытекает из одной фразы: если хочешь, чтобы две части страницы раскрывались независимо, им нужны две границы.
Размещай границы рядом с местом, где нужны данные, а не только в корне маршрута. Инстинкт из старых паттернов — одно состояние загрузки на страницу. Suspense поощряет обратное: опускай границу ниже, чтобы статичная оболочка и уже разрешённые данные отрисовывались сразу, а каждая медленная область несла собственный fallback. Страница превращается в набор независимо подтекающих потоком дыр в мгновенно появляющейся раскладке.
// Оболочка + header + nav мгновенны. Каждая медленная область
// подтекает потоком в свой слот за собственным fallback.
export default function Dashboard() {
return (
<DashboardShell>
<Header /> {/* отрисовывается сразу */}
<Suspense fallback={<FeedSkeleton />}>
<ActivityFeed /> {/* подтекает потоком, когда готов */}
</Suspense>
<Suspense fallback={<StatsSkeleton />}>
<RevenueStats /> {/* подтекает потоком независимо */}
</Suspense>
</DashboardShell>
);
}Теперь медленный <RevenueStats> никогда не держит ленту в заложниках, и ни один из них не держит оболочку. Пользователь видит структуру за 80 мс и наблюдает, как контент наполняется, — куда лучше воспринимаемая производительность, чем один долгий пустой экран.
Одна корневая граница — это «всё или ничего»; дробные границы раскрывают контент постепенно, по мере прибытия. Это и есть ключевой компромисс. Единственный Suspense на верхнем уровне даёт простейший код и худший опыт при несбалансированных таймингах загрузки: страница раскрывается со скоростью самого медленного потомка. Дробные границы стоят нескольких лишних компонентов, но позволяют каждой области раскрываться со скоростью её собственных данных.
// ❌ всё или ничего: страница появляется со скоростью САМОГО МЕДЛЕННОГО потомка
<Suspense fallback={<FullPageSpinner />}>
<Header /><Feed /><Stats /><Recommendations />
</Suspense>
// ✅ прогрессивно: каждая область появляется со скоростью СВОИХ СОБСТВЕННЫХ данных
<Header />
<Suspense fallback={<FeedSkeleton />}><Feed /></Suspense>
<Suspense fallback={<StatsSkeleton />}><Stats /></Suspense>
<Suspense fallback={<RecsSkeleton />}><Recommendations /></Suspense>В модели потокового SSR / RSC это буквально сетевой стриминг: сервер сбрасывает оболочку и контент каждой границы по мере его разрешения, так что дробные границы меняют не только когда React раскрывает, — они меняют, какие байты приходят первыми.
Режим отказа: единственный Suspense на верхнем уровне, гасящий всю страницу всякий раз, когда грузится любая мелкая часть. Это тот самый антипаттерн, ради убийства которого существует урок. Одинокий виджет аналитики, список «люди, на которых стоит подписаться», некритичный график — любой из них, приостановившись, спрячет всю твою, в остальном готовую страницу за корневым fallback. Пользователь платит задержкой самого медленного компонента, чтобы увидеть самый быстрый контент.
// Один медленный, низкоприоритетный виджет держит всю страницу в заложниках.
<Suspense fallback={<FullPageSpinner />}>
<CriticalArticle /> {/* готов за 90мс... */}
<RelatedPosts /> {/* ...но заблокирован на 1.4с этим */}
</Suspense>Но обратное — тоже отказ: граница вокруг каждого листа порождает мерцающее месиво скелетов и прыгающую раскладку, которое читается как дёрганье, а не прогресс. Поэтому дисциплина в том, чтобы сопоставлять границы осмысленным независимым областям загрузки — оборачивать то, у чего реально разные тайминги, которые пользователю полезно видеть разрешающимися отдельно, и позволять тому, что грузится вместе, делить одну границу. Размещение границ — это вопрос суждения о том, какие куски должны появляться вместе.
Страница профиля: от одной корневой границы к прогрессивному раскрытию. На странице три области с очень разными таймингами загрузки — header пользователя (быстрый), лента постов (средняя) и виджет «рекомендуемые подписки», который бьёт в медленный сервис рекомендаций.
Было — одна корневая граница. Быстрый header держится в заложниках медленного виджета:
export default function ProfilePage({ id }: { id: string }) {
return (
<Suspense fallback={<FullPageSpinner />}>
<ProfileHeader id={id} /> {/* разрешается за ~100мс */}
<PostFeed id={id} /> {/* разрешается за ~400мс */}
<SuggestedFollows id={id} /> {/* разрешается за ~1.5с */}
</Suspense>
);
}
// Пользователь смотрит на полностраничный спиннер 1.5с, потом всё появляется разом.Стало — границы размещены рядом с каждой потребностью в данных. Оболочка и header отрисовываются мгновенно; каждая более медленная область подтекает потоком в свой слот:
export default function ProfilePage({ id }: { id: string }) {
return (
<ProfileLayout>
{/* Header разрешается быстро — дай ему свою границу, чтобы его
ничто из расположенного ниже не блокировало. */}
<Suspense fallback={<HeaderSkeleton />}>
<ProfileHeader id={id} />
</Suspense>
{/* Лента — основной контент: своя граница, чтобы она раскрывалась,
как только готова, а не когда готов виджет. */}
<Suspense fallback={<FeedSkeleton />}>
<PostFeed id={id} />
</Suspense>
{/* Медленный, низкоприоритетный виджет изолирован. Он может занять 1.5с,
не влияя ни на что выше себя. */}
<Suspense fallback={<FollowsSkeleton />}>
<SuggestedFollows id={id} />
</Suspense>
</ProfileLayout>
);
}Теперь header появляется за ~100 мс, лента за ~400 мс, а рекомендации наполняются последними — пользователь читает профиль и посты, пока медленный виджет ещё грузится. Те же компоненты, те же данные, то же суммарное время; кардинально лучший опыт, решённый целиком тем, где сидят границы. Заметь, чего мы не делали: мы не оборачивали каждый аватар и каждую строку поста в собственный Suspense — это бы мерцало. Три границы сопоставлены трём действительно независимым областям загрузки.
▸Почему это работает
Почему «рядом с данными» бьёт «одну в корне»? Потому что воспринимаемая производительность определяется временем до первого осмысленного контента, а не временем до полной загрузки. Пользователь, видящий структуру страницы и свой header за 100 мс, воспринимает приложение как быстрое, даже если виджет в сайдбаре всё ещё крутится на 1,5 с. Корневая граница выбрасывает этот выигрыш, связывая первую отрисовку с последней. Дробные границы их развязывают — а в этом и весь смысл потокового UI: отгружай готовое, остальное доставляй потоком.
▸Граничные случаи
Дробнее не всегда лучше. Два случая тянут в обратную сторону. Первый — стабильность раскладки: если независимое раскрытие областей заставляет контент ниже прыгать по мере замены каждого скелета, резервируй место в скелете (фиксированные высоты) или сгруппируй области под одной границей, чтобы они раскрывались вместе, — общая граница и есть правильный инструмент, когда независимое раскрытие лишь вызвало бы сдвиг. Второй — намеренная группировка: иногда две части бессмысленны порознь (график и его легенда, цена и её бейдж валюты); оборачивай такие в одну границу нарочно, чтобы они никогда не появлялись наполовину отрендеренными. Правило не «максимизируй границы», а «одна граница на группу, которая должна появляться вместе».
Дашборд оборачивает свой header, основной график и медленный сторонний виджет «рекомендуемые действия» в единственный корневой <Suspense fallback={<FullPageSpinner />}>. Header и график разрешаются за ~150мс; виджет занимает ~1.4с. Что испытывает пользователь и какое senior-исправление?
<Suspense> раскрывает контент с гранулярностью своей границы: всё под одной границей — это один атомарный кусок, появляющийся вместе, когда разрешится её самый медленный приостанавливающийся потомок. Поэтому где ты размещаешь границу — UX-решение. Единственный корневой Suspense — это всё или ничего: страница раскрывается со скоростью самого медленного потомка, и один медленный низкоприоритетный виджет может погасить в остальном готовую страницу (тот самый режим отказа, который убивает этот урок). Размещение границ рядом с местом, где нужна каждая часть данных, даёт статичной оболочке и быстрым областям отрисоваться сразу, пока медленные области подтекают потоком в свои fallback — куда лучше воспринимаемая производительность, а в потоковом SSR/RSC буквально лучший порядок байтов. Но не дроби чрезмерно: граница на каждый лист мерцает и сдвигает раскладку. Дисциплина — это одна граница на группу контента, которая действительно должна появляться вместе: сопоставляй границы независимым областям загрузки, а не каждому компоненту.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.