Данные прямо в компоненте: await вместо useEffect
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Загрузка данных в браузере стоила трёх состояний, эффекта с уборкой и лишнего круга по сети: сначала приезжает разметка, потом бандл, потом «Загрузка…» — и только теперь браузер идёт спрашивать данные. Серверный компонент убирает весь этот обвес: await стоит прямо в теле функции, и первый же кадр приходит с данными.
Компонент из главы про эффекты выглядел примерно так: состояние под данные, эффект, уборка против гонки ответов и разметка на оба случая — ветку ошибки я убрал, чтобы она не заслоняла главное. Всё это вы уже писали — здесь важна не конструкция, а порядок событий. Прежде чем запускать, ответьте себе: что покажет средняя строка вывода — что окажется на странице в тот момент, когда ответ уже пришёл, но состояние ещё не обновлено?
tasks === null, потому что эффект по определению запускается после коммита: на первом кадре данных нет и быть не может. Дальше приходит ответ, происходит второй рендер — и только тогда список.Дорога тут длиннее, чем кажется по коду. Сервер отдал разметку, браузер скачал и разобрал бандл, React смонтировал дерево, эффект отработал — и вот теперь ушёл запрос за данными. Данные всё это время лежали на сервере: чтобы их попросить, пришлось сперва доставить в браузер программу, которая умеет просить.
Ветки «грузится», «ошибка», «готово» заводились не от любви к состояниям, а потому что ответ приходит позже кадра. Убери отставание — и описывать станет нечего.
Серверный компонент выполняется там, где данные уже лежат: рядом с базой, файлами и внутренними сервисами. Просить их через сеть ему не у кого — ему нужно только дождаться ответа. А ждать функция умеет с части I.
Ни useState, ни useEffect, ни флага alive. От компонентов первой главы этот отличается ровно одним словом — async. Возвращается разметка, уже собранная с данными, и в браузер едет она, а не программа, которая эти данные добудет.
Что при этом делает сервер, видно, если проделать его работу руками. Компонент — обычная функция; асинхронная функция возвращает промис; сервер зовёт функцию и ждёт промис, прежде чем превращать элемент в текст.
TasksPage() вернул не элемент, а обещание элемента — и до await с ним нельзя сделать ничего полезного. В этом и разница между двумя мирами: браузеру ждать некогда, он обязан немедленно нарисовать хоть что-то, а сервер собирает ответ и вправе подождать.
Компилятор против не будет: в типах React 19 ReactNode включает Promise, иначе async-компоненты не проходили бы проверку вовсе. Возражает сам React — уже на запуске.
React получил промис вместо разметки, попробовал ещё раз, ещё — и через сотню кругов сдался. Собранный для прода, он говорит номерами: Minified React error #482, а по ссылке из сообщения лежит полный текст — «Only Server Components can be async at the moment». Отсюда следствие, о котором легко забыть: "use client" и async в одном файле несовместимы. Клиентский компонент ждёт иначе — через use() и границу Suspense, и об этом была статья «use() и Suspense».
await — это пауза. Пока функция стоит на первом ответе, второй запрос ещё не начинался, хотя мог бы идти рядом. Прикиньте, сколько миллисекунд займёт каждый из двух блоков ниже, и запустите.
Двести против ста. Два await подряд выстраивают запросы в очередь, и страница ждёт их сумму, а не самый долгий из них. С тремя запросами по 100 мс разница будет уже втрое.
Лечится это не хитрым приёмом, а вопросом «зависит ли второй запрос от первого». Если нет — вызовы разводят с ожиданием: запустить оба сразу, а await поставить там, где значение действительно понадобилось.
Promise.all из части I и эта форма делают одно и то же — работа стартует в момент вызова, а не в момент await. Выбор между ними чисто вкусовой: Promise.all короче, отдельные промисы читаются лучше, когда ответы нужны в разных местах разметки.
Только помните про отказ: у Promise.all первая же ошибка роняет всё. Когда часть данных необязательна, берут allSettled — или ловят ошибку внутри своей загрузки.
Второй вид водопада — по дереву: родитель ждёт свои данные, отрисовывается, и только тогда ребёнок начинает свой запрос. Соседние ветви сервер обходит параллельно, а вложенность неизбежно выстраивает цепочку. Тут Promise.all не поможет — помогает другое: отпустить медленную ветку ждать отдельно, не задерживая остальную страницу. Об этом следующая статья главы.
Пока данные грузил браузер, повторные запросы лечили подъёмом состояния и контекстом: один компонент грузит, остальные читают. У серверных компонентов этот приём часто неприменим: шапка живёт в раскладке, карточка в странице, а раскладка не умеет передавать пропсы в children — она получает их уже готовым элементом. Ответ здесь другой: пусть каждый спрашивает сам, а одинаковые вопросы схлопнутся в один.
Ключ в том, что в карту кладётся не результат, а начатый промис. Второй вызов приходит, когда первый ещё в пути, — и получает ту же самую работу, а не запускает свою. Именно поэтому оба компонента получили один объект: inHeader === inCard оказалось true.
Ровно это делает cache() из React — своими руками карту писать не нужно.
Карту заводит только серверная сборка React. В браузерной — той, что стоит в песочнице, — cache() пропускает вызов насквозь, поэтому пример выше и написан руками.
У fetch в Next такое поведение встроено: два одинаковых GET в одном проходе рендера уйдут по сети один раз. Для базы, файлов и клиентов чужих сервисов оборачивайте сами.
Живёт эта карта ровно один ответ сервера: два посетителя — две карты, и ничего лишнего друг другу они не покажут. Кеш, который переживает запрос, — совсем другая машина и отдельная глава про кеширование.
await в компоненте бросает исключение так же, как везде. Необработанное улетит в ближайший error.tsx — файл из главы про App Router. Если же ошибка ожидаемая и страница без этих данных имеет смысл, её ловят на месте.
Отдельный случай — «данные есть, но их нет»: запрос отработал и вернул null. Это не ошибка, а честный ответ, и для него в Next есть notFound().
И главное ограничение: пока компонент ждёт, ждёт и ответ. Быстрым запросам это только на пользу — кадра с «загрузкой» не будет вовсе. А медленный отчёт задержит собой всю страницу, и посетитель проведёт это время на пустой вкладке. Лечится это не возвращением к useEffect, а тем, что страницу начинают отдавать частями: loading.tsx из главы про App Router и границы Suspense внутри неё. Об этом — следующая статья.
| useEffect в браузере | await в серверном компоненте | |
|---|---|---|
| Кто идёт за данными | браузер посетителя | сервер, рядом с базой |
| Когда стартует запрос | после загрузки и разбора бандла | во время сборки ответа |
| Первый кадр | «Загрузка…» | готовая разметка |
| Состояний в коде | данные, ошибка, ожидание | ни одного |
| Гонка ответов | своя уборка в эффекте | не бывает: один вызов, один await |
| Секреты и пароли | только через свой /api | остаются на сервере |
| Кому доступно | любому компоненту | только серверному |
Обратите внимание на последнюю строку: это не «старый способ и новый». Данные, которые зависят от действий пользователя уже на странице, по-прежнему грузит клиентская половина — и делает это через use(), Suspense или обычный эффект. Меняется другое: первая порция данных, та, ради которой открыли адрес, больше не требует круга по сети.
- Серверный компонент — обычная async-функция:
awaitстоит в её теле, а сервер зовёт функцию и ждёт промис, прежде чем превращать результат в разметку. - Загрузка через useEffect стоила лишнего круга по сети и трёх состояний, потому что эффект случается после первого кадра; здесь данные есть до кадра, и описывать ожидание нечем.
- Два await подряд — это очередь: независимые запросы разводят через Promise.all или начинают до первого ожидания.
- Одинаковые запросы из разных мест дерева схлопывает
cache(): карта «аргументы → начатый промис» живёт ровно один ответ сервера. - Ошибка из await летит в error.tsx, пустой результат превращают в 404 через notFound(), а ловят на месте только то, без чего страница всё равно осмысленна.
- Ждать умеет лишь серверная половина:
"use client"иasyncв одном файле несовместимы, клиенту остаются use() и Suspense.
1/4Серверный компонент делает подряд два await: const user = await loadUser() и const banner = await loadBanner(). Каждый запрос идёт 100 мс, и второй не нуждается в результате первого. Сколько будет собираться ответ?
2/4Что окажется в HTML, который сервер отдал браузеру, если данные грузит клиентский компонент с useEffect?
3/4Шапка в раскладке и подпись под статьёй обе зовут getUser(1), обёрнутый в cache(). Сколько запросов уйдёт в базу?
4/4Файл начинается с "use client", а компонент в нём объявлен как async function с await внутри. Что произойдёт?
Убрать водопад
Профиль собирается 300 мс: три запроса по 100 мс идут друг за другом. Настоящая зависимость среди них только одна. Перепишите компонент так, чтобы страница собиралась за 200 мс, и объясните, почему не за 100.
Зависимость здесь ровно одна: loadPosts нужен user.id. Баннер не зависит ни от чего, поэтому его запрос запускают вместе с запросом пользователя, а ждут в самом конце.
Было 300 мс, стало 200: цепочка «пользователь → записи» осталась, потому что она настоящая, а баннер больше никого не ждёт. Если бы loadPosts принимал внешний id, а не user.id, все три ушли бы в один Promise.all и страница ждала бы 100 мс.
Из браузера на сервер
Клиентский компонент грузит заметки через свой маршрут /api/notes. Перепишите его в серверный: обращение к данным прямое, всё лишнее — вон. Посчитайте, сколько строк и сколько файлов исчезло.
Компонент выполняется на сервере, значит идти в базу можно прямо из него, а состояние «ещё не пришло» описывать нечем — до первого кадра данные уже есть.
Исчезли: useState, useEffect, флаг alive, ветка «Загрузка…» и маршрут /api/notes, который существовал только ради этого компонента. Файла app/api/notes/route.ts можно не писать вовсе — если, конечно, в него не ходит кто-то ещё.
Один поход вместо двух
Шапка и подпись показывают имя одного и того же автора и загружают его каждая сама. В базу уходит два одинаковых запроса. Почините, не трогая ни разметку компонентов, ни их пропсы.
Пробрасывать данные через пропсы неудобно: шапка живёт в раскладке, подпись — в странице, общего родителя с данными у них нет. Вместо этого оборачивают саму функцию загрузки.
Ключ карты — аргументы вызова, поэтому getUser(1) и getUser(2) сходят в базу порознь, а два getUser(1) в одном ответе — один раз. Карта живёт ровно один запрос страницы: следующему посетителю достанется своя.