use() и Suspense: ожидание как часть разметки
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Пока данные едут, на экране должно что-то быть — и до сих пор это была ваша забота: флаг «идёт загрузка» в состоянии, ветка в разметке, и так на каждый блок данных. use() переворачивает картину: компонент читает промис так, будто значение уже есть, а ожиданием занимается <Suspense> — граница в разметке, у которой написано, что показать вместо ещё не готового куска.
Загрузка данных эффектом разобрана в главе про эффекты: запрос уходит в useEffect, ответ ложится в состояние, а до ответа компонент рисует заглушку. Всё честно и всё вручную. Прежде чем запускать, посчитайте: сколько строк «отрисовка» напечатает пример?
Две: первая с name === null, вторая — после ответа. Заглушка появилась не потому, что React что-то знает про ожидание, а потому, что вы написали if. Ожидание здесь — обычное состояние компонента, ничем не отличающееся от счётчика.
Пока блок данных один, это терпимо. Когда их пять — пять флагов, пять веток, пять мест, где легко перепутать «ещё не пришло» и «пришло пустое». И ни одного места, где написано, как выглядит страница целиком, пока данных нет.
fetch в песочнице запрещён, поэтому «сеть» здесь и дальше — таймер. React разницы не видит: ему нужен промис, а откуда тот взялся, ему безразлично.
use() принимает промис и возвращает то, чем тот разрешится. Если значения ещё нет, компонент приостанавливается: React бросает работу над ним и идёт вверх по дереву искать ближайшую границу <Suspense>, чтобы показать её fallback. Тот же самый профиль, но без состояния и без ветки. Сколько раз выполнится тело Profile?
Три раза — и это первая неожиданность. Две строки печатаются сразу, одна за другой, третья приходит после ответа, между отметками в 100 и 500 миллисекунд. Первый проход обрывается на use: значения нет, возвращать из компонента нечего, и React выбрасывает всю его работу целиком — недорисованного JSX не бывает. Второй проход тоже упирается в use и тоже ничего не даёт; зачем он нужен — во врезке ниже. После него React показывает fallback и ждёт. Когда промис разрешился, Profile зовётся в третий раз, и use возвращает строку.
На экране от этих метаний не видно ничего: приостановка случается в фазе рендера, до коммита — той самой границы из статьи «Триггер, рендер, коммит». В DOM попадает либо заглушка, либо готовый результат.
Главное в этом коде — чего в нём нет. В Profile нет ни флага, ни ветки, ни слова «загрузка»: он написан так, как будто данные всегда на месте. Что показывать, «пока не на месте», сказано снаружи и один раз — в границе. Компонент про ожидание не знает вовсе.
И вывод, который понадобится через раздел: тело компонента может быть вызвано сколько угодно раз, прежде чем хоть что-то окажется на экране. Пока в теле только чтение пропсов и состояния, это безобидно.
Поставим рядом с Profile обычного соседа, который ничего не ждёт, и посмотрим на порядок строк.
«тело Profile», «тело Profile», «тело Sidebar» — и только после ответа ещё одна пара. В первом проходе до соседа дело не дошло: Profile приостановился раньше, и остаток границы остался неисследованным. Второй проход React делает ради него — чтобы дойти до соседей приостановленного компонента и, если они тоже чего-то ждут, начать их ожидание сейчас, а не после того, как дождётся первый. В React 19 это называют предварительной отрисовкой соседей.
Считать проходы в своём коде не нужно — важно другое: их число вам не принадлежит. React зовёт тело столько раз, сколько нужно ему, и это ещё один довод за чистое тело компонента.
React помечает сам объект промиса: пока тот в работе, на нём появляется поле со статусом, а после разрешения — ещё и поле со значением. Третье поле в выводе, с подчёркиванием в начале имени, — служебное: его дописывает отладочная сборка для своих сообщений, и в боевой его нет. Загляните в промис до запуска и после.
Поэтому последний проход не приостанавливается: use видит готовый результат прямо на объекте и возвращает его без всякого ожидания. И поэтому же тот же самый промис, показанный через минуту в другом месте страницы, не вызовет ни одной приостановки — результат уже при нём. Из этой мелочи вырастает весь следующий раздел.
Раз тело выполняется заново, важно, что именно в нём написано. Перенесём создание промиса внутрь компонента — так и тянет сделать, когда запрос зависит от пропса. Что окажется на экране через секунду?
Вечная «Загрузка…» и счётчик попыток, выросший за секунду до нескольких штук — точное число зависит от того, как быстро браузер крутит этот цикл, но растёт оно бесконечно. Цикл замкнулся: тело создаёт промис, use приостанавливает компонент, промис разрешается, React зовёт тело заново — и получает новый промис, про который ничего не знает. Компонент никогда не догонит собственные данные, а если бы за промисом стоял настоящий запрос, он уходил бы на сервер снова и снова.
Отсюда правило: промис обязан пережить отрисовку. Он приходит откуда угодно, кроме тела компонента, — с модульного уровня, из кеша по ключу, пропсом сверху.
useMemo здесь не спасает: React вправе выбросить запомненное и посчитать заново — это оптимизация, а не гарантия. Кеш, от которого зависит правильность, должен быть настоящим.
Чаще всего промис приходит пропсом, и тип у него самый обычный — Promise<string>. Компилятор проверяет его как любой другой пропс.
| Что написали | Что скажет компилятор |
|---|---|
<Name source="Рико" /> при source: Promise<string> | Тип «string» не может быть назначен для типа «Promise<string>». |
Обратились к name.then, забыв, что use промис уже развернул | Свойство «then» не существует в типе «string». |
Теперь настоящий кеш: словарь по ключу, промис создаётся при первом обращении и остаётся жить в нём. Мы дважды переключим профиль — сначала на второй, потом обратно на первый. Сколько раз напечатается «новый запрос»?
Два раза. Третий показ достался кешу: loadUser(1) вернул тот самый промис, который уже разрешился, — use отдал значение сразу, и «Загрузка…» даже не мигнула. Обратите внимание и на «нарисовали»: на приостановленной попытке этой строки нет, потому что до неё дело не дошло.
Писать словарь руками приходится редко. Обычно кеш промисов — забота библиотеки данных или фреймворка: они помнят, какой запрос по какому ключу уже начат, и на повторный вызов отдают прежний промис. Именно этого не хватает голому React: use() умеет читать промис, но не умеет его хранить.
В Next.js обычная схема другая: запрос начинает серверный компонент и передаёт незавершённый промис пропсом вниз, в клиентский компонент, а тот делает use. Тело клиентского компонента промис не создаёт, поэтому и цикла из этого раздела не возникает. Про эту границу — глава про серверные и клиентские компоненты; там же разбирается loading.tsx, который Next.js разворачивает в ту же самую границу <Suspense>.
<Suspense> — это не «место, где крутится спиннер», а кусок дерева, который целиком заменяется запасной разметкой, если хоть что-то внутри приостановилось. Два блока под одной границей: быстрый готов через 200 миллисекунд, медленный — через 600. Что будет на экране на 400-й?
Выход не в коде внутри компонентов, а расстановкой границ. Дадим каждому блоку свою.
Теперь быстрый блок приходит на 200-й миллисекунде и ждёт своей заглушки только медленный. Компоненты Block при этом не изменились ни на символ: порядок появления кусков страницы задан разметкой, а не логикой.
| Расстановка границ | Что видно, пока медленный блок не готов |
|---|---|
| Одна граница вокруг обоих блоков | Заглушка вместо обоих: готовый быстрый блок ждёт вместе с медленным |
| Своя граница у каждого блока | Быстрый блок на месте, заглушка только на медленном |
| Граница выше по дереву, вокруг всей страницы | Заглушка вместо всей страницы, включая шапку и меню |
| Границы нет вовсе | React не найдёт, чем заменить приостановленный кусок, — до разрешения промиса на месте дерева пусто |
Отсюда практика: границу ставят вокруг того, что не жалко показать позже остального. Шапка, меню и текст статьи — сразу, комментарии и «похожие товары» — под своей границей, каждый со своей заглушкой.
Правила хуков из первой статьи главы держатся на ячейках: React раздаёт их по порядку вызовов, поэтому useState в ветке ломает нумерацию. use ячейку не занимает — он ничего не помнит между отрисовками, а просто читает то, что ему дали. Значит, его можно вызывать в условии. Проверим заодно и границу: что окажется на экране на 100-й миллисекунде — «Привет, гость» или «Загрузка…»?
use в ветке отработал штатно — ни ошибки, ни предупреждения: это единственное исключение из правил хуков, и сделано оно намеренно.Свобода у use всё же не полная. use вызывают только внутри компонента или своего хука: в обработчике события ему делать нечего — приостанавливать там уже нечего, отрисовка давно закончилась. И его нельзя обернуть в try…catch: отклонённый промис — это не исключение вашей функции. Его ловит граница ошибок — последняя статья главы.
| useState, useEffect | use | |
|---|---|---|
Вызов в 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, ни слова «загрузка». Следите за тем, чтобы промис не рождался в теле компонента.
Промис создаётся один раз на модульном уровне, компонент читает его через use, заглушка переезжает в границу.
Компонент стал короче ровно на то, что не было его работой: ожидание описано снаружи, а внутри осталась одна строка разметки. Цена типизирована сама собой — use вернул number, потому что промис объявлен как Promise<number>.
Загрузка, которая не кончается
Пример показывает «Загрузка…» и никогда не показывает город, хотя промис разрешается за 200 миллисекунд. Найдите причину и почините — так, чтобы City по-прежнему получал id пропсом и работал для любого id.
loadCity(id) вызывается в теле компонента и каждый раз создаёт новый промис. Лечится не переносом вызова, а кешем: один промис на каждый id.
Теперь после разрешения промиса повторная отрисовка получает из словаря тот же самый объект — с уже проставленным результатом, — и use возвращает значение, не приостанавливая компонент. Строка с unmount больше не нужна: цикла нет.
Заголовок не должен ждать комментарии
Заголовок готов через 100 миллисекунд, комментарии — через 900, но на 400-й миллисекунде в выводе всё ещё «Загрузка…». Переставьте границы так, чтобы заголовок появлялся сразу, а своя заглушка была только у комментариев. Компоненты Piece трогать нельзя.
Заголовок не приостанавливается надолго, а комментарии приостанавливаются надолго — значит, граница нужна вокруг комментариев, а не вокруг обоих.
На 400-й миллисекунде в выводе будет «Статья про use()Комментарии грузятся…». Заголовок всё же приостановит дерево на первые 100 миллисекунд — над ним границы нет, поэтому до разрешения его промиса на экране пусто; на практике такие данные начинают грузить раньше или тоже накрывают границей.