Данные прямо в компоненте: await вместо useEffect

серверные≈ 11 минут

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

Загрузка данных в браузере стоила трёх состояний, эффекта с уборкой и лишнего круга по сети: сначала приезжает разметка, потом бандл, потом «Загрузка…» — и только теперь браузер идёт спрашивать данные. Серверный компонент убирает весь этот обвес: await стоит прямо в теле функции, и первый же кадр приходит с данными.

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

Компонент из главы про эффекты выглядел примерно так: состояние под данные, эффект, уборка против гонки ответов и разметка на оба случая — ветку ошибки я убрал, чтобы она не заслоняла главное. Всё это вы уже писали — здесь важна не конструкция, а порядок событий. Прежде чем запускать, ответьте себе: что покажет средняя строка вывода — что окажется на странице в тот момент, когда ответ уже пришёл, но состояние ещё не обновлено?

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

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

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

Серверный компонент выполняется там, где данные уже лежат: рядом с базой, файлами и внутренними сервисами. Просить их через сеть ему не у кого — ему нужно только дождаться ответа. А ждать функция умеет с части I.

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

Ни useState, ни useEffect, ни флага alive. От компонентов первой главы этот отличается ровно одним словом — async. Возвращается разметка, уже собранная с данными, и в браузер едет она, а не программа, которая эти данные добудет.

Что при этом делает сервер, видно, если проделать его работу руками. Компонент — обычная функция; асинхронная функция возвращает промис; сервер зовёт функцию и ждёт промис, прежде чем превращать элемент в текст.

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

TasksPage() вернул не элемент, а обещание элемента — и до await с ним нельзя сделать ничего полезного. В этом и разница между двумя мирами: браузеру ждать некогда, он обязан немедленно нарисовать хоть что-то, а сервер собирает ответ и вправе подождать.

Компилятор против не будет: в типах React 19 ReactNode включает Promise, иначе async-компоненты не проходили бы проверку вовсе. Возражает сам React — уже на запуске.

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

React получил промис вместо разметки, попробовал ещё раз, ещё — и через сотню кругов сдался. Собранный для прода, он говорит номерами: Minified React error #482, а по ссылке из сообщения лежит полный текст — «Only Server Components can be async at the moment». Отсюда следствие, о котором легко забыть: "use client" и async в одном файле несовместимы. Клиентский компонент ждёт иначе — через use() и границу Suspense, и об этом была статья «use() и Suspense».

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

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

Двести против ста. Два await подряд выстраивают запросы в очередь, и страница ждёт их сумму, а не самый долгий из них. С тремя запросами по 100 мс разница будет уже втрое.

Лечится это не хитрым приёмом, а вопросом «зависит ли второй запрос от первого». Если нет — вызовы разводят с ожиданием: запустить оба сразу, а await поставить там, где значение действительно понадобилось.

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

Promise.all из части I и эта форма делают одно и то же — работа стартует в момент вызова, а не в момент await. Выбор между ними чисто вкусовой: Promise.all короче, отдельные промисы читаются лучше, когда ответы нужны в разных местах разметки.

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

Второй вид водопада — по дереву: родитель ждёт свои данные, отрисовывается, и только тогда ребёнок начинает свой запрос. Соседние ветви сервер обходит параллельно, а вложенность неизбежно выстраивает цепочку. Тут Promise.all не поможет — помогает другое: отпустить медленную ветку ждать отдельно, не задерживая остальную страницу. Об этом следующая статья главы.

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

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

Ключ в том, что в карту кладётся не результат, а начатый промис. Второй вызов приходит, когда первый ещё в пути, — и получает ту же самую работу, а не запускает свою. Именно поэтому оба компонента получили один объект: inHeader === inCard оказалось true.

Ровно это делает cache() из React — своими руками карту писать не нужно.

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

Карту заводит только серверная сборка React. В браузерной — той, что стоит в песочнице, — cache() пропускает вызов насквозь, поэтому пример выше и написан руками.

У fetch в Next такое поведение встроено: два одинаковых GET в одном проходе рендера уйдут по сети один раз. Для базы, файлов и клиентов чужих сервисов оборачивайте сами.

Живёт эта карта ровно один ответ сервера: два посетителя — две карты, и ничего лишнего друг другу они не покажут. Кеш, который переживает запрос, — совсем другая машина и отдельная глава про кеширование.

await в компоненте бросает исключение так же, как везде. Необработанное улетит в ближайший error.tsx — файл из главы про App Router. Если же ошибка ожидаемая и страница без этих данных имеет смысл, её ловят на месте.

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

Отдельный случай — «данные есть, но их нет»: запрос отработал и вернул null. Это не ошибка, а честный ответ, и для него в Next есть notFound().

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

И главное ограничение: пока компонент ждёт, ждёт и ответ. Быстрым запросам это только на пользу — кадра с «загрузкой» не будет вовсе. А медленный отчёт задержит собой всю страницу, и посетитель проведёт это время на пустой вкладке. Лечится это не возвращением к 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.

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

Зависимость здесь ровно одна: loadPosts нужен user.id. Баннер не зависит ни от чего, поэтому его запрос запускают вместе с запросом пользователя, а ждут в самом конце.

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

Было 300 мс, стало 200: цепочка «пользователь → записи» осталась, потому что она настоящая, а баннер больше никого не ждёт. Если бы loadPosts принимал внешний id, а не user.id, все три ушли бы в один Promise.all и страница ждала бы 100 мс.

Из браузера на сервер

Клиентский компонент грузит заметки через свой маршрут /api/notes. Перепишите его в серверный: обращение к данным прямое, всё лишнее — вон. Посчитайте, сколько строк и сколько файлов исчезло.

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

Компонент выполняется на сервере, значит идти в базу можно прямо из него, а состояние «ещё не пришло» описывать нечем — до первого кадра данные уже есть.

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

Исчезли: useState, useEffect, флаг alive, ветка «Загрузка…» и маршрут /api/notes, который существовал только ради этого компонента. Файла app/api/notes/route.ts можно не писать вовсе — если, конечно, в него не ходит кто-то ещё.

Один поход вместо двух

Шапка и подпись показывают имя одного и того же автора и загружают его каждая сама. В базу уходит два одинаковых запроса. Почините, не трогая ни разметку компонентов, ни их пропсы.

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

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

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

Ключ карты — аргументы вызова, поэтому getUser(1) и getUser(2) сходят в базу порознь, а два getUser(1) в одном ответе — один раз. Карта живёт ровно один запрос страницы: следующему посетителю достанется своя.