React Server Components: граница, payload и что через них проходит
RSC исполняются только на сервере и не шлют JS — клиент получает сериализованное дерево (RSC payload). "use client" задаёт границу, а не помечает компонент; через неё проходят только сериализуемые props. Хуки на сервере невозможны: у компонента нет клиентской жизни.
Миграция на App Router должна была уменьшить бандл. Вместо этого через месяц клиентский JS аналитического дашборда вырос на 180KB. Виновника искали день: кому-то понадобился onClick на строке таблицы, он получил знакомую ошибку — «Event handlers cannot be passed to Client Component props» — и «починил» быстрым способом: влепил "use client" в начало DashboardLayout.tsx. Эта директива не пометила один компонент как клиентский. Она провела границу — и всё, что импортировал DashboardLayout, целое поддерево графиков, форматтеров и табличной библиотеки на 90KB, уехало в клиентский бандл вместе с ним. Хуже: через транзитивные импорты прицепом приехали локали date-fns и полуинициализированный аналитический SDK. Команда читала "use client" как по-компонентную аннотацию. Это не так. Это дверь в графе модулей, и всё, что импортируется за этой дверью, отправляется в браузер.
К концу урока вы будете точно знать, где проводить границу "use client" — и как аудировать существующую кодовую базу, чтобы найти места, где она кровоточит в бандл.
Компоненты, которые никогда не отгружаются
Зачем это вообще нужно? Каждый KB, который браузер скачивает, нужно распарсить и исполнить, прежде чем страница станет интерактивной, — а большая часть типичной Next.js кодовой базы — это логика рендеринга, которой в браузере делать нечего. RSC позволяет провести эту границу явно и не платить ничего за её существование.
React Server Component (RSC, серверный компонент React) — это компонент, который исполняется только на сервере — при сборке или на запрос — и чей код никогда не попадает в клиентский бандл. Вторая часть фразы — весь экономический аргумент. Классический SSR-компонент рендерится на сервере и ещё раз на клиенте при гидратации, поэтому его код отгружается в браузер; RSC рендерится один раз, на сервере, и браузер получает только его результат. Поэтому компонент может делать то, что браузерному коду не позволено никогда: ходить в базу напрямую, читать файловую систему, использовать тяжёлый markdown-рендерер или подсветку синтаксиса, держать API-секреты — ничто из этого не покидает сервер.
// app/orders/page.tsx — Server Component (по умолчанию в app/)
import { db } from "~/lib/db"; // server-only: никогда не попадает в клиентский бандл
import { formatMoney } from "~/lib/money";
export default async function OrdersPage() {
// Загрузка данных рядом с компонентом, которому они нужны.
// Ни useEffect, ни танцев с loading-стейтом, ни API-роута посередине.
const orders = await db.order.findMany({ take: 50, orderBy: { createdAt: "desc" } });
return (
<table>
<tbody>
{orders.map((o) => (
<tr key={o.id}>
<td>{o.customerEmail}</td>
<td>{formatMoney(o.totalCents)}</td>
</tr>
))}
</tbody>
</table>
);
}Обратите внимание, чего здесь нет: ни useEffect + fetch + спиннера, ни API-эндпоинта, построенного только чтобы возить данные в браузер. Компонент — async, он сам await-ит свои данные, и слой данных живёт рядом с разметкой, которая их потребляет. Это убивает классический водопад клиентской загрузки, где браузер качает JS, исполняет его, потом обнаруживает, что нужны данные, потом делает ещё один круг по сети — на сервере компонент и база часто в одном датацентре, и этот круг стоит ~1 мс вместо ~100 мс.
Что клиент получает на самом деле — это RSC payload: компактное сериализованное описание отрендеренного дерева — результат серверных компонентов плюс плейсхолдеры («здесь отрендери клиентский компонент из чанка X, вот его props») везде, где встречается клиентский компонент. React на клиенте строит из payload дерево, не исполняя серверный код. Это же делает возможной мягкую навигацию: клик по ссылке запрашивает RSC payload нового маршрута, и React вливает его в существующее дерево — клиентские компоненты в постоянном layout сохраняют состояние (полунабранный поиск переживает переход), потому что payload — это описание для реконсиляции, а не HTML-страница на замену.
Что получает браузер для Server Component, который рендерит таблицу на 50 строк, используя markdown-библиотеку на 90KB?
”use client” — граница, а не метка
Самое частое senior-заблуждение про RSC: читать "use client" как «этот компонент — клиентский». На деле директива объявляет границу в графе зависимостей модулей: этот файл и всё достижимое через его импорты принадлежит клиентскому бандлу. Вы не аннотируете каждый интерактивный компонент — вы помечаете точки входа, где серверное дерево передаёт управление клиентской территории, и бандлер обходит граф импортов оттуда. Именно поэтому однострочный «фикс» из Hook утащил 180KB: пометить файл layout — значит пометить всё его поддерево импортов.
Отсюда два следствия. Первое — практический паттерн: двигайте клиентские границы к листьям. Держите layout, страницы и компоненты с тяжёлыми данными серверными, а интерактивные острова вырезайте минимальными — <AddToCartButton>, а не <ProductPage>. Второе: клиентский компонент не может импортировать серверный (импорт затащил бы серверный код в клиентский граф — сборка либо обработает его как клиентский компонент, либо упадёт на server-only API). Но клиентский компонент может получить серверно отрендеренный контент как children или любой другой prop: сервер рендерит внутреннее дерево, а клиентский компонент вставляет готовый результат на место. Переплетение достигается композицией, а не импортом.
// theme-provider.tsx
"use client";
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<"light" | "dark">("light");
return <ThemeCtx.Provider value={{ theme, setTheme }}>{children}</ThemeCtx.Provider>;
}
// app/layout.tsx — Server Component
export default function RootLayout() {
return (
<ThemeProvider>
{/* По-прежнему рендерится на сервере, ноль клиентского JS —
проходит СКВОЗЬ клиентский компонент как children, а не импортируется им. */}
<HeavyServerSidebar />
</ThemeProvider>
);
}Что проходит через границу: только сериализуемое
Прежде чем передать prop через границу "use client", спросите себя: переживёт ли это значение сериализацию в JSON, передачу по сети и оживление в совершенно другом процессе? Если нет — prop не пройдёт.
Props, текущие из серверного компонента в клиентский, пересекают настоящую границу процессов и времени — на сервере они сериализуются в RSC payload, в браузере оживают. Поэтому они обязаны быть сериализуемыми RSC-сериализатором React: JSON-примитивы, простые объекты и массивы, Date, Map/Set, FormData, типизированные массивы, JSX и промисы (которые достримливаются по мере разрешения). Что пройти не может: функции (замыкание onClick захватило серверный скоуп — оживить его в браузере невозможно), экземпляры классов (объект модели Prisma бросит ошибку или молча потеряет прототип) и всё, что несёт живые ресурсы — сокеты, хендлы базы. Поэтому «Functions cannot be passed directly to Client Component props» — ошибка границы, а не синтаксиса. И поэтому два легитимных обходных пути устроены именно так: передавайте вниз данные и пусть обработчик живёт в клиентском компоненте — либо передавайте Server Action (функцию с "use server"), сериализуемую ровно потому, что по проводу идёт ссылка — идентификатор, который клиент вызовет сетевым запросом, — а не само замыкание.
Страница дашборда имеет постоянный сайдбар, делающий запросы к БД, и кнопку ThemeToggle, хранящую предпочтение в localStorage. Где разместить границу 'use client', чтобы минимизировать клиентский бандл?
Та же граница объясняет, почему хуки и состояние в серверных компонентах невозможны. useState предполагает экземпляр, который переживает перерендеры и получает события во времени; серверный компонент исполняется один раз на запрос рендера и исчезает — нет второго рендера, через который состоянию жить, нет цикла событий, слушающего клики, нет времени жизни. useEffect предполагает DOM, на который можно влиять; его нет. Правило «никаких хуков в серверных компонентах» — не ограничение фреймворка, которое когда-нибудь починят: оно прямо следует из того, что такое серверный компонент — функция из данных в результат, исполняемая в среде без интерактивной жизни.
▸Почему это работает
Почему загрузку данных стоит размещать в компоненте, а не централизовать в лоадерах или API-роутах? Потому что компонент — это единица, которая знает, что ей нужно. При централизованной загрузке каждое изменение потребностей компонента расходится вверх по сигнатурам лоадеров и формам эндпоинтов — и подкрадывается over-fetching, потому что лоадер обслуживает объединение нужд всех потребителей. С RSC компонент await-ит ровно свой запрос; React дедуплицирует одинаковые fetch внутри одного прохода рендера, а параллельные сиблинги грузятся конкурентно. Риск водопада инвертируется: анти-паттерн, за которым надо следить, — последовательные await в родителе, блокирующие детей, которые могли бы грузиться параллельно. Лечится ранним стартом промисов с передачей их вниз или Promise.all — а не возвращением центрального лоадера.
Серверный компонент передаёт props в клиентский. Какой prop сломает сборку или сериализацию в рантайме?
- 01Объясните, почему 'use client' называют границей, а не меткой, и каково практическое правило её размещения.
- 02Что может пересечь границу props сервер→клиент, что не может, и почему серверным компонентам недоступны хуки?
Server Component исполняется только на сервере, и его код никогда не попадает в клиентский бандл — браузер получает RSC payload (сериализованное описание отрендеренного дерева) с плейсхолдерами, указывающими на чанки клиентских компонентов и их props. Этот payload React реконсилирует при мягкой навигации — поэтому клиентское состояние в постоянных layout переживает смену маршрута. Раз код не отгружается, серверным компонентам можно то, что браузерному коду нельзя: прямые запросы к базе, секреты, тяжёлые библиотеки с нулевой клиентской ценой — а загрузка данных живёт рядом с компонентом как простой await, удаляя водопад «скачай JS, потом сходи за данными» и API-роуты, существовавшие только как транспорт. Директива “use client” — граница в графе зависимостей модулей, а не по-компонентная метка: файл и всё его поддерево импортов уезжают на клиент, поэтому границам место у листьев — минимальные интерактивные острова, а не layout. Клиентский компонент не может импортировать серверный, но серверный JSX проходит сквозь него как children: переплетайте композицией. Через границу props обязаны пережить сериализацию: примитивы, простые объекты, Date, Map, Set, FormData, JSX и промисы проходят; функции, экземпляры классов и живые ресурсы — нет: замыкание не оживить в другом процессе. Server Actions (серверные действия) проходят, потому что сериализуется только вызываемая ссылка. А хуки на сервере невозможны по определению: у серверного компонента нет клиентской жизни — ни перерендеров для состояния, ни цикла событий, ни DOM — это чистая функция из данных в результат, исполняемая один раз. Теперь, увидев регрессию размера бандла после «быстрого фикса», первым делом проверьте: не сдвинулась ли граница "use client" — и сколько графа импортов ушло вместе с ней.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.