open atlas
↑ К треку
React с нуля до senior RCT · 03 · 02

Suspense для данных: контракт «брось promise», use() и границы как UX-дизайн

Suspense переворачивает загрузку данных: компонент читает promise через use(), React подвешивает рендер до ближайшей границы, resolve повторяет рендер. Размещение границ — UX-дизайн, отклонённые promise уходят в error boundary, а без кеша fetch в рендере зависает в вечном цикле.

RCT Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

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?

Вспомните перед уходом
  1. 01
    Пройдите полный жизненный цикл Suspense для компонента, читающего данные через use(): пути pending, resolved и rejected — и объясните точно, почему механизму нужен кеш.
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 7 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.