Suspense для данных: контракт «брось promise», use() и границы как UX-дизайн
Suspense переворачивает загрузку данных: компонент читает promise через use(), React подвешивает рендер до ближайшей границы, resolve повторяет рендер. Размещение границ — UX-дизайн, отклонённые promise уходят в error boundary, а без кеша fetch в рендере зависает в вечном цикле.
PR с миграцией назывался «Suspense everywhere» и демо было прекрасным: каждый тернарник с isLoading удалён, компоненты просто читают данные. За неделю случились два инцидента. Сначала стейджинг начал выжигать ядро CPU на 100% — разработчик написал use(fetch('/api/user')) прямо в теле компонента, и профайлер показал тысячи рендеров того же компонента в секунду: создать promise, подвеситься, повторить рендер, создать новый promise, подвеситься снова — и так вечно. На экране только fallback, network-вкладка заполняется сотнями одинаковых запросов. Затем, когда это закрыли кешем, ожил саппорт-канал: «весь дашборд гаснет, когда я меняю фильтр по дате». Команда обернула целую страницу в один <Suspense> у корня, и любой виджет, перезапрашивающий данные где угодно, заменял все двенадцать виджетов одним гигантским спиннером. Одна неделя, одна фича, два противоположных урока: Suspense — это контракт между брошенным promise и границей, а место границы — не сантехника, а сам UX загрузки, спроектированный в JSX.
Контракт: подвеситься броском, продолжиться повтором
Зачем это знать инженеру? Потому что понимание контракта «бросить и повторить» — единственный способ диагностировать оба отказа из Хука: вечный цикл и тотальное гашение страницы — и расставлять границы осознанно, а не по наитию.
Механически Suspense — протокол исключений. Когда компонент не может отрендериться, потому что данные не готовы, он бросает thenable (promise-подобный объект). React ловит его, раскручивая рендер — помните, рендер чист и выбрасываем, так что отказ от попытки бесплатен, — поднимается до ближайшей границы <Suspense> и коммитит её fallback вместо поддерева. Затем React подписывается на брошенный promise; когда тот резолвится, React повторяет рендер поддерева с нуля. На повторе компонент выполняется снова, у источника данных уже есть значение, чтение возвращается синхронно, и настоящий UI коммитится. В самом компоненте нет ветки загрузки — «не готово» не состояние, которое он моделирует, а исключение, которое он бросает.
use() — санкционированный примитив чтения: const user = use(userPromise) возвращает значение, если promise зарезолвлен, бросает promise для подвешивания, если pending, и бросает причину отклонения, если rejected. В отличие от хуков, use можно вызывать в условиях и циклах — это чтение, а не регистрация состояния. Сам promise должен быть создан в другом месте (родителем, лоадером роутера, кешем) и передан вниз или найден по ключу; работа компонента — только читать.
const cache = new Map();
function fetchUser(id) { // идентичность: тот же id → тот же promise
if (!cache.has(id)) {
cache.set(id, fetch(`/api/users/${id}`).then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}));
}
return cache.get(id);
}
function Profile({ id }) {
const user = use(fetchUser(id)); // подвешивается, пока pending
return <h1>{user.name}</h1>;
}
// <ErrorBoundary fallback={<Oops />}>
// <Suspense fallback={<Skeleton />}>
// <Profile id={42} />
// </Suspense>
// </ErrorBoundary>Почему голый fetch в рендере зацикливается навсегда
Вот ловушка из Хука, механически. Рендер чист и перезапускаем: React может вызвать компонент много раз, и каждое подвешивание выбрасывает попытку целиком. Если тело компонента само создаёт promise — use(fetch(url)), — то повтор после резолва снова выполняет тело и создаёт новенький, pending promise. Тот, что зарезолвился, ушёл вместе с выброшенным рендером; новый подвешивает; React подписывается, тот резолвится, повтор, новый promise, подвешивание. Цикл не сходится, и каждая итерация шлёт настоящий сетевой запрос. Лечение — идентичность: какое-то хранилище вне рендера должно возвращать тот же promise для того же логического запроса, чтобы use() на повторе получил уже зарезолвленный promise и прочитал его синхронно. Это хранилище и есть кеш — Map по параметрам запроса в игрушечной версии, query-библиотека или лоадер фреймворка в продакшене. Это структурная причина, по которой документация React говорит: загрузка данных с Suspense предназначена для фреймворков и кеширующих библиотек, а не для ручной сборки — контракт прост, кеш (инвалидация, вытеснение, дедупликация) и есть настоящий продукт.
Компонент вызывает use(fetch('/api/user')) прямо в теле. Fallback рендерится вечно, network-вкладка заполняется одинаковыми запросами. Каков механизм?
Границы — это UX-дизайн, а ошибки — близнец границы
Где вы ставите <Suspense>, там и появляются спиннеры — это дизайнерское решение, выраженное структурой компонентов. Одна граница у корня страницы — всё или ничего: проще всего, но любой медленный виджет гасит всё (второй инцидент из Хука). Граница на каждый виджет — секции проявляются независимо: быстрые сразу, медленные показывают локальные скелетоны — ценой «попкорн-UI», когда контент выскакивает в случайном порядке и дёргает раскладку. Сеньорский паттерн — осознанная группировка: оберните то, что пользователь воспринимает как одно целое (шапка + сводка), в одну границу, чтобы они появились вместе, а длиннохвостым секциям (аналитика, рекомендации) дайте свои. Вложенные границы компонуются: подвешивание ловит ближайшая граница-предок, так что внутренние границы ограничивают внутренние состояния загрузки, а внешняя покрывает первый маунт. Для управления порядком проявления — сверху вниз вместо «кто первый зарезолвился» — экспериментальный SuspenseList координировал несколько границ; пока он не стабилен, тот же эффект даёт структура границ или объединение promise выше по дереву.
Отклонение — зеркальный путь: когда ожидаемый promise отклоняется, повтор бросает причину отклонения во время рендера, и это исключение летит до ближайшего error boundary — не до границы Suspense. Продакшен-дерево поэтому ставит их парой: ErrorBoundary снаружи, Suspense внутри, на каждый регион. Честный трейдофф: fallback заменяет контент. Для обновлений после первой загрузки заменить живые данные спиннером часто хуже, чем показать слегка устаревшие, — для этого существует startTransition: обновления, помеченные как transition, не включают fallback уже раскрытой границы; React держит старый UI, пока новое дерево подвешено в фоне.
Дашборд оборачивает двенадцать виджетов в одну корневую границу Suspense. Смена фильтра заставляет один виджет перезапросить данные — и весь дашборд гаснет до спиннера. Какое изменение чинит UX, не отказываясь от Suspense?
- 01Пройдите полный жизненный цикл Suspense для компонента, читающего данные через use(): пути pending, resolved и rejected — и объясните точно, почему механизму нужен кеш.
- 02Команда спрашивает, где ставить границы Suspense на новом дашборде и как должны вести себя обновления. Каков сеньорский плейбук с трейдоффами каждого размещения?
Suspense заменяет моделируемое состояние загрузки протоколом исключений. Компонент читает данные через use(promise): зарезолвленный promise синхронно возвращает значение, pending — бросается, React ловит его, выбрасывая чистую попытку рендера, коммитит fallback ближайшей границы <Suspense>, подписывается и при резолве повторяет поддерево с нуля; отклонённый бросает свою причину до ближайшего error boundary — поэтому стандартная пара это ErrorBoundary-снаружи-Suspense-внутри. Семантика повтора диктует требование кеша: повтор заново выполняет тело компонента, и тело, создающее свой promise (use(fetch(url))), производит свежий pending promise на каждую попытку и подвешивается в вечном цикле настоящих сетевых запросов — для сходимости хранилище вне рендера должно возвращать тот же promise для того же логического запроса, что и дают query-библиотеки и лоадеры фреймворков, и почему ручная Suspense-загрузка заканчивается на игрушечных кешах. Размещение границ и есть UX: подвешивание всплывает до ближайшей границы, так что одна корневая граница означает раскрытие «всё или ничего», а пограничные регионы раскрываются независимо ценой попкорн-порядка — группируйте то, что пользователь воспринимает единым, ограничивайте остальное и используйте startTransition для обновлений, чтобы перезапрос не стирал ничего уже показанного. use можно вызывать условно — это чтение, а не регистрация хука, — а promise должен создавать родитель, лоадер или кеш, но никогда сам читающий компонент. Теперь, когда увидишь границу, гасящую слишком много страницы, или компонент, зависающий в вечном цикле, — знаешь точный рычаг: размещение границы — это UX-дизайн, а идентичность promise — работа кеша.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.