use() и Suspense: ожидание как часть разметки

свои хуки≈ 11 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Пока данные едут, на экране должно что-то быть — и до сих пор это была ваша забота: флаг «идёт загрузка» в состоянии, ветка в разметке, и так на каждый блок данных. use() переворачивает картину: компонент читает промис так, будто значение уже есть, а ожиданием занимается <Suspense> — граница в разметке, у которой написано, что показать вместо ещё не готового куска.

Разделы темы:·

Загрузка данных эффектом разобрана в главе про эффекты: запрос уходит в useEffect, ответ ложится в состояние, а до ответа компонент рисует заглушку. Всё честно и всё вручную. Прежде чем запускать, посчитайте: сколько строк «отрисовка» напечатает пример?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Две: первая с name === null, вторая — после ответа. Заглушка появилась не потому, что React что-то знает про ожидание, а потому, что вы написали if. Ожидание здесь — обычное состояние компонента, ничем не отличающееся от счётчика.

Пока блок данных один, это терпимо. Когда их пять — пять флагов, пять веток, пять мест, где легко перепутать «ещё не пришло» и «пришло пустое». И ни одного места, где написано, как выглядит страница целиком, пока данных нет.

fetch в песочнице запрещён, поэтому «сеть» здесь и дальше — таймер. React разницы не видит: ему нужен промис, а откуда тот взялся, ему безразлично.

use() принимает промис и возвращает то, чем тот разрешится. Если значения ещё нет, компонент приостанавливается: React бросает работу над ним и идёт вверх по дереву искать ближайшую границу <Suspense>, чтобы показать её fallback. Тот же самый профиль, но без состояния и без ветки. Сколько раз выполнится тело Profile?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Три раза — и это первая неожиданность. Две строки печатаются сразу, одна за другой, третья приходит после ответа, между отметками в 100 и 500 миллисекунд. Первый проход обрывается на use: значения нет, возвращать из компонента нечего, и React выбрасывает всю его работу целиком — недорисованного JSX не бывает. Второй проход тоже упирается в use и тоже ничего не даёт; зачем он нужен — во врезке ниже. После него React показывает fallback и ждёт. Когда промис разрешился, Profile зовётся в третий раз, и use возвращает строку.

На экране от этих метаний не видно ничего: приостановка случается в фазе рендера, до коммита — той самой границы из статьи «Триггер, рендер, коммит». В DOM попадает либо заглушка, либо готовый результат.

Главное в этом коде — чего в нём нет. В Profile нет ни флага, ни ветки, ни слова «загрузка»: он написан так, как будто данные всегда на месте. Что показывать, «пока не на месте», сказано снаружи и один раз — в границе. Компонент про ожидание не знает вовсе.

И вывод, который понадобится через раздел: тело компонента может быть вызвано сколько угодно раз, прежде чем хоть что-то окажется на экране. Пока в теле только чтение пропсов и состояния, это безобидно.

Поставим рядом с Profile обычного соседа, который ничего не ждёт, и посмотрим на порядок строк.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

«тело Profile», «тело Profile», «тело Sidebar» — и только после ответа ещё одна пара. В первом проходе до соседа дело не дошло: Profile приостановился раньше, и остаток границы остался неисследованным. Второй проход React делает ради него — чтобы дойти до соседей приостановленного компонента и, если они тоже чего-то ждут, начать их ожидание сейчас, а не после того, как дождётся первый. В React 19 это называют предварительной отрисовкой соседей.

Считать проходы в своём коде не нужно — важно другое: их число вам не принадлежит. React зовёт тело столько раз, сколько нужно ему, и это ещё один довод за чистое тело компонента.

React помечает сам объект промиса: пока тот в работе, на нём появляется поле со статусом, а после разрешения — ещё и поле со значением. Третье поле в выводе, с подчёркиванием в начале имени, — служебное: его дописывает отладочная сборка для своих сообщений, и в боевой его нет. Загляните в промис до запуска и после.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Поэтому последний проход не приостанавливается: use видит готовый результат прямо на объекте и возвращает его без всякого ожидания. И поэтому же тот же самый промис, показанный через минуту в другом месте страницы, не вызовет ни одной приостановки — результат уже при нём. Из этой мелочи вырастает весь следующий раздел.

Раз тело выполняется заново, важно, что именно в нём написано. Перенесём создание промиса внутрь компонента — так и тянет сделать, когда запрос зависит от пропса. Что окажется на экране через секунду?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Вечная «Загрузка…» и счётчик попыток, выросший за секунду до нескольких штук — точное число зависит от того, как быстро браузер крутит этот цикл, но растёт оно бесконечно. Цикл замкнулся: тело создаёт промис, use приостанавливает компонент, промис разрешается, React зовёт тело заново — и получает новый промис, про который ничего не знает. Компонент никогда не догонит собственные данные, а если бы за промисом стоял настоящий запрос, он уходил бы на сервер снова и снова.

Отсюда правило: промис обязан пережить отрисовку. Он приходит откуда угодно, кроме тела компонента, — с модульного уровня, из кеша по ключу, пропсом сверху.

useMemo здесь не спасает: React вправе выбросить запомненное и посчитать заново — это оптимизация, а не гарантия. Кеш, от которого зависит правильность, должен быть настоящим.

Чаще всего промис приходит пропсом, и тип у него самый обычный — Promise<string>. Компилятор проверяет его как любой другой пропс.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
Что написалиЧто скажет компилятор
<Name source="Рико" /> при source: Promise<string>Тип «string» не может быть назначен для типа «Promise<string>».
Обратились к name.then, забыв, что use промис уже развернулСвойство «then» не существует в типе «string».

Теперь настоящий кеш: словарь по ключу, промис создаётся при первом обращении и остаётся жить в нём. Мы дважды переключим профиль — сначала на второй, потом обратно на первый. Сколько раз напечатается «новый запрос»?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Два раза. Третий показ достался кешу: loadUser(1) вернул тот самый промис, который уже разрешился, — use отдал значение сразу, и «Загрузка…» даже не мигнула. Обратите внимание и на «нарисовали»: на приостановленной попытке этой строки нет, потому что до неё дело не дошло.

Писать словарь руками приходится редко. Обычно кеш промисов — забота библиотеки данных или фреймворка: они помнят, какой запрос по какому ключу уже начат, и на повторный вызов отдают прежний промис. Именно этого не хватает голому React: use() умеет читать промис, но не умеет его хранить.

В Next.js обычная схема другая: запрос начинает серверный компонент и передаёт незавершённый промис пропсом вниз, в клиентский компонент, а тот делает use. Тело клиентского компонента промис не создаёт, поэтому и цикла из этого раздела не возникает. Про эту границу — глава про серверные и клиентские компоненты; там же разбирается loading.tsx, который Next.js разворачивает в ту же самую границу <Suspense>.

<Suspense> — это не «место, где крутится спиннер», а кусок дерева, который целиком заменяется запасной разметкой, если хоть что-то внутри приостановилось. Два блока под одной границей: быстрый готов через 200 миллисекунд, медленный — через 600. Что будет на экране на 400-й?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
«Загрузка…». Быстрый блок давно готов, и React даже отрисовал его при повторной попытке — но та же попытка снова уткнулась в медленный, и коммитить оказалось нечего. Граница показывает своих детей всех разом или не показывает никого.

Выход не в коде внутри компонентов, а расстановкой границ. Дадим каждому блоку свою.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Теперь быстрый блок приходит на 200-й миллисекунде и ждёт своей заглушки только медленный. Компоненты Block при этом не изменились ни на символ: порядок появления кусков страницы задан разметкой, а не логикой.

Расстановка границЧто видно, пока медленный блок не готов
Одна граница вокруг обоих блоковЗаглушка вместо обоих: готовый быстрый блок ждёт вместе с медленным
Своя граница у каждого блокаБыстрый блок на месте, заглушка только на медленном
Граница выше по дереву, вокруг всей страницыЗаглушка вместо всей страницы, включая шапку и меню
Границы нет вовсеReact не найдёт, чем заменить приостановленный кусок, — до разрешения промиса на месте дерева пусто

Отсюда практика: границу ставят вокруг того, что не жалко показать позже остального. Шапка, меню и текст статьи — сразу, комментарии и «похожие товары» — под своей границей, каждый со своей заглушкой.

Правила хуков из первой статьи главы держатся на ячейках: React раздаёт их по порядку вызовов, поэтому useState в ветке ломает нумерацию. use ячейку не занимает — он ничего не помнит между отрисовками, а просто читает то, что ему дали. Значит, его можно вызывать в условии. Проверим заодно и границу: что окажется на экране на 100-й миллисекунде — «Привет, гость» или «Загрузка…»?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
«Загрузка…»: гость исчез вместе с соседом, потому что граница у них общая. А вот сам use в ветке отработал штатно — ни ошибки, ни предупреждения: это единственное исключение из правил хуков, и сделано оно намеренно.

Свобода у use всё же не полная. use вызывают только внутри компонента или своего хука: в обработчике события ему делать нечего — приостанавливать там уже нечего, отрисовка давно закончилась. И его нельзя обернуть в try…catch: отклонённый промис — это не исключение вашей функции. Его ловит граница ошибок — последняя статья главы.

useState, useEffectuse
Вызов в if или в цикленельзя: ячейки раздаются по порядкуможно: ячейку не занимает
Где вызыватьверхний уровень компонента или своего хукакомпонент или свой хук, но в любом месте тела
Как выражено ожиданиефлаг в состоянии и ветка в разметкеприостановка до ближайшей границы

use умеет читать не только промис, но и контекст: use(ThemeContext) работает там, где useContext уже нельзя, — например внутри ветки. Про сам контекст — статья главы о состоянии на весь экран.

  • use(promise) возвращает значение промиса, а пока значения нет — приостанавливает компонент: ветка «идёт загрузка» уходит из кода.
  • Запасную разметку показывает ближайшая граница <Suspense fallback={…}>, и она же определяет, какой кусок экрана исчезнет целиком.
  • При приостановке тело компонента выполняется заново, и не один раз, — поэтому промис нельзя создавать внутри него: новый промис на каждой попытке даёт вечную загрузку.
  • Промис должен пережить отрисовку — модульный уровень, кеш по ключу или пропс сверху; в Next.js его обычно начинает серверный компонент.
  • Граница ждёт самого медленного из детей: чтобы готовый блок появился раньше, ему нужна своя граница.
  • use — не хук: его можно вызывать в условии и в цикле, но только во время отрисовки; отклонённый промис ловит граница ошибок из следующей статьи.

1/4Компонент печатает строку в начале тела и вызывает use(promise); промис создан снаружи и разрешается через 300 мс. Сколько раз напечатается строка?

2/4Компонент вызывает use(loadUser(id)), где loadUser каждый раз создаёт новый промис. Компонент обёрнут в Suspense. Что будет на экране через секунду?

3/4Под одной границей Suspense два компонента: один получает данные через 200 мс, другой через 600. Когда на экране появится первый?

4/4Можно ли вызвать use внутри if — например, читать промис только когда пропс не равен null?

Ожидание уезжает в разметку

Компонент грузит цену вручную: состояние, эффект, ветка. Перепишите его на use() и <Suspense> так, чтобы в самом компоненте не осталось ни useState, ни useEffect, ни слова «загрузка». Следите за тем, чтобы промис не рождался в теле компонента.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Промис создаётся один раз на модульном уровне, компонент читает его через use, заглушка переезжает в границу.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Компонент стал короче ровно на то, что не было его работой: ожидание описано снаружи, а внутри осталась одна строка разметки. Цена типизирована сама собой — use вернул number, потому что промис объявлен как Promise<number>.

Загрузка, которая не кончается

Пример показывает «Загрузка…» и никогда не показывает город, хотя промис разрешается за 200 миллисекунд. Найдите причину и почините — так, чтобы City по-прежнему получал id пропсом и работал для любого id.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

loadCity(id) вызывается в теле компонента и каждый раз создаёт новый промис. Лечится не переносом вызова, а кешем: один промис на каждый id.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь после разрешения промиса повторная отрисовка получает из словаря тот же самый объект — с уже проставленным результатом, — и use возвращает значение, не приостанавливая компонент. Строка с unmount больше не нужна: цикла нет.

Заголовок не должен ждать комментарии

Заголовок готов через 100 миллисекунд, комментарии — через 900, но на 400-й миллисекунде в выводе всё ещё «Загрузка…». Переставьте границы так, чтобы заголовок появлялся сразу, а своя заглушка была только у комментариев. Компоненты Piece трогать нельзя.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Заголовок не приостанавливается надолго, а комментарии приостанавливаются надолго — значит, граница нужна вокруг комментариев, а не вокруг обоих.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

На 400-й миллисекунде в выводе будет «Статья про use()Комментарии грузятся…». Заголовок всё же приостановит дерево на первые 100 миллисекунд — над ним границы нет, поэтому до разрешения его промиса на экране пусто; на практике такие данные начинают грузить раньше или тоже накрывают границей.