Перейти к содержимому

React Server Components: граница сервера и клиента

React Server Components (RSC) — архитектурный сдвиг, который React готовил несколько лет, а Next.js App Router сделал массовым. Идея проста и радикальна одновременно: часть компонентов React исполняется только на сервере и никогда не попадает в браузер как JavaScript. Браузер получает их готовый HTML-подобный результат — сериализованное дерево с инструкциями, где какие места должны стать интерактивными. Базовая механика расписана в официальном гайде по Server Components.

Это меняет экономику фронтенда. Раньше «весь React» ехал в бандл: и разметка, и логика, и библиотеки. С RSC бандл уменьшается до клиентских островков, а серверная часть работает там, где есть прямой доступ к базе, файловой системе и приватным ключам. Но эта сила имеет цену: жёсткая граница между сервером и клиентом, правила сериализации и новый класс ошибок, которых не существовало в классическом SPA.

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

Серверный компонент — это функция, которую Node.js (или Edge runtime) вызывает один раз на запрос (или на сборку). Она может быть асинхронной, читать файлы, ходить в БД по приватной сети, держать секреты. Её результат — не DOM-элементы, а сериализованное дерево: типы компонентов, строки текста, инструкции «здесь вставить клиентский компонент с такими-то props».

// app/users/page.tsx — серверный компонент, никаких директив
import { db } from '@/lib/db'; // этот импорт НЕ попадёт в браузер
import { UserCard } from './user-card'; // клиентский островок
export default async function UsersPage() {
// выполнится на сервере: прямой доступ к БД без API-слоя
const users = await db.user.findMany({
where: { active: true },
take: 50,
});
return (
<ul>
{users.map((u) => (
// пропсы сериализуются и передаются через границу
<UserCard key={u.id} user={u} />
))}
</ul>
);
}

Важно: db здесь тянет за собой цепочку зависимостей, которая остаётся на сервере. Prisma-client, bcrypt, Stripe SDK с секретным ключом — всё это физически отсутствует в клиентском бандле. Раньше для этого нужен был отдельный бэкенд с REST-слоем; теперь граница проходит внутри одного дерева компонентов.

Директива 'use client' помечает точку, от которой компонент и всё его поддерево становятся клиентскими. Но важно понимать механику: граница проходит по импортам, а не по JSX-вложенности.

app/page.tsx (сервер)
└─ <UserCard /> ← импорт из файла с 'use client'
└─ user-card.tsx (клиент)
└─ <Avatar /> ← импорт внутри клиентского файла — тоже клиент!

Если клиентский UserCard импортирует Avatar (без своей директивы), Avatar попадает в бандл автоматически. Обратное неверно: серверный компонент не может быть импортирован из клиентского файла. Это жёсткое правило архитектуры, а не соглашение.

'use client';
import { Avatar } from './avatar'; // попадёт в бандл вместе с UserCard
// import { getUsers } from './queries'; // ❌ ошибка: серверный модуль в клиенте
export function UserCard({ user }: { user: User }) {
const [expanded, setExpanded] = useState(false);
return (
<li onClick={() => setExpanded((v) => !v)}>
<Avatar src={user.avatarUrl} />
{expanded && <p>{user.bio}</p>}
</li>
);
}

Через границу допустимы только пропсы. Компонент не может «перекинуть» функцию-обработчик из серверного кода в клиентский напрямую — функции не сериализуются. Вместо этого: Server Action как проп (об этом в главе про Server Actions), либо клиентский колбэк, определённый в клиентском файле.

Серверный компонент — это не «компонент со суперсилойми», а компонент с ограничениями. Нельзя:

  • хуки состояния и эффектов: useState, useReducer, useEffect, useLayoutEffect, useRef для DOM;
  • обработчики событий: onClick, onChange и любые колбэки на действия пользователя;
  • браузерные API: window, document, localStorage, IntersectionObserver — они не существуют на сервере;
  • контекст React — createContext/useContext доступны только на клиенте;
  • не-serializable пропсы на границу: функции (кроме Server Actions), Map/Set, class-инстансы.

Часть этих вещей падает с понятной ошибкой компиляции, часть — с рантайм-ошибкой «window is not defined» при SSR. Дисциплина: если компонент реагирует на пользователя — он клиентский; если готовит данные — серверный.

// ❌ плохо: серверный компонент с состоянием
export default function Counter() {
const [count, setCount] = useState(0); // ошибка: hooks не работают в RSC
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
// ✅ хорошо: минимальный клиентский островок
'use client';
export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

Передача данных через границу: сериализация props

Заголовок раздела «Передача данных через границу: сериализация props»

Единственный мост между сервером и клиентом — сериализуемые пропсы. Next.js прогоняет пропсы клиентских компонентов через JSON.stringify-подобную процедуру с расширениями (Promise, Date поддерживаются, функции — только Server Actions). Это накладывает следствия, которые в продакшене бьют регулярно:

  1. Тяжёлые объекты — лишний вес HTML. Передал весь объект пользователя с 30 полями вместо трёх нужных — каждый байт уедет в каждый RSC payload каждому пользователю. Дерево пропсов на границе — точка оптимизации: мапь данные до передачи.
  2. Классы теряют методы. new Date() дойдёт как строка/Date, но твой class User с методами .fullName() превратится в пlain object. Маппинг на DTO — обязателен.
  3. Циклические ссылки рвут сериализацию. Prisma-модель с relation-графом иногда невозможно передать как есть — выбирай поля явно.
// app/products/page.tsx — серверный: мапим до границы
import { BuyButton } from './buy-button';
export default async function ProductsPage() {
const products = await db.product.findMany({ take: 20 });
return (
<ul>
{products.map((p) => (
// только нужные поля, plain объект
<BuyButton
key={p.id}
product={{ id: p.id, name: p.name, price: p.priceCents / 100 }}
/>
))}
</ul>
);
}

Клиентский компонент получает с сервера HTML и должен «ожить»: React скачивает бандл, выполняет компоненты, сопоставляет виртуальное дерево с реальным DOM и навешивает обработчики. Это и есть гидратация (цена подробно разобрана в Rendering on the Web), и у неё две цены:

  • Скачивание и парсинг JS. Каждый клиентский островок тянет за собой React-рантайм, хуки, библиотеки. Большое дерево из клиентских компонентов = сотни килобайт JS на 3G-соединении.
  • Main-thread время. Гидратация выполняется на главном потоке и конкурирует с обработкой ввода пользователя. На слабом телефоне «страница загрузилась, но не нажимается» — симптом перегруженной гидратации.

Инструменты диагностики: вкладка Coverage в DevTools (сколько JS реально исполнилось), Lighthouse TBT (Total Blocking Time), <script>-анализатор в бандл-отчётах.

Цель архитектуры RSC — максимум серверного дерева, минимум клиентского. Практические приёмы:

  1. Директиву ставь максимально ниже. Форма с одним useState не должна делать клиентским весь раздел. Вынеси интерактивный элемент в отдельный файл с 'use client' и импортируй в серверную страницу.
// app/article/[id]/page.tsx — серверный: вся статья, SEO, метаданные
export default async function ArticlePage({ params }: Props) {
const article = await getArticle((await params).id);
return (
<main>
<ArticleBody html={article.html} />
{/* островок: только он едет в браузер как JS */}
<LikeButton articleId={article.id} initialLikes={article.likes} />
</main>
);
}
  1. Серверные данные — в серверные компоненты, клиенту — минимум. Таблицу с сортировкой можно сделать серверной (сортировка через searchParams и серверный рендер), а клиентским оставить только выпадашку фильтров.
  2. Контекст — либо корень клиентского поддерева, либо composable-обёртки. AuthProvider на клиенте означает, что всё внутри — клиентское. Часто лучше: серверная страница читает сессию и раздаёт готовые данные островкам.
  3. Тяжёлые библиотеки ниже границы. Chart.js, монако-редактор, карты — оборачивай в клиентские компоненты и грузи лениво (next/dynamic), чтобы серверная часть страницы не ждала их бандл.

Результат правильного разбиения: бандл приложения на сотни килобайт меньше, TTI на слабых устройствах — на секунды быстрее, а секреты и тяжёлые запросы остались на сервере, где им место.

  1. 'use client' в корне приложения. Поставил директиву в корневой layout «на всякий случай» — весь сайт стал клиентским, RSC-смысл потерян, бандл — размером с классический SPA. Плохо: директива вверху дерева. Хорошо: островки внизу.

  2. Импорт серверного модуля из клиентского. import { db } from '@/lib/db' в файле с 'use client' — либо ошибка сборки (Next.js блокирует серверные модули), либо молчаливая утечка: модуль попал в бандл вместе с env-подстановками. Правило: серверные модули импортируются только из серверных файлов.

  3. Несериализуемые пропсы через границу. Передача Map, функции-колбэка или class-инстанса клиентскому компоненту ломает сериализацию с загадочной ошибкой. Маппи всё в plain objects, действия — через Server Actions.

  4. Передача лишних данных «про запас». Весь объект юзера с хешем пароля и телефоном — в пропсы клиентской карточки. Помимо веса payload это потенциальная утечка PII в клиентский бандл-лог. Плохо: user={user}. Хорошо: явный whitelist полей.

  5. window/localStorage в «нейтральном» компоненте. Утилита, импортируемая и серверным, и клиентским компонентом, обратилась к localStorage — падает SSR с «window is not defined». Решения: гард typeof window !== 'undefined', перенос в клиентский файл, либо useEffect (не выполняется на сервере).

  6. useEffect как способ «догрузить серверные данные». Классическая привычка из SPA: страница-скелетон + useEffect(fetch). В RSC это антипаттерн: данные надёжнее, быстрее и SEO-дружелюбнее грузить прямо в серверном компоненте. useEffect оставь для чисто клиентских вещей: подписки, таймеры, DOM-интеграции.

  1. Что такое React Server Component и чем он отличается от SSR? SSR генерирует HTML всего дерева на сервере для первой загрузки; RSC — архитектура, при которой часть компонентов исполняется только на сервере и не попадает в JS-бандл вовсе. RSC может сочетаться со статикой, ISR и streaming.
  2. Почему нельзя useState в серверном компоненте? Потому что серверный компонент исполняется один раз на сервере и не имеет жизненного цикла в браузере: состояние, эффекты и события не имеют к нему смысла. Интерактив — удел клиентских компонентов.
  3. Какие пропсы можно передать из серверного компонента в клиентский? Только сериализуемые: plain объекты, массивы, строки, числа, boolean, null, Date, Promise. Функции — только Server Actions. Map/Set/class-инстансы — нельзя.
  4. Что такое гидратация и почему она дорогая? Процесс сопоставления клиентского JS с уже отрисованным HTML и навешивания интерактивности. Дорога, потому что требует скачивания и исполнения JS на главном потоке, конкурируя с вводом пользователя.
  5. Как определить, где проходит граница сервер/клиент в проекте? По файлам с 'use client': всё, что импортируется из такого файла (транзитивно), — клиентское. Всё остальное — серверное. Ошибка «server-only module in client bundle» укажет нарушение.
  6. Как передать данные из клиентского компонента на сервер? Только через Server Action (вызов серверной функции из клиента) или через router/navigation с последующей серверной обработкой. Напрямую «вверх по границе» данные не передаются.
  7. Паттерн «островков» — в чём суть? Большая серверная часть страницы (данные, разметка, SEO) + минимальные клиентские компоненты для интерактивных зон. Снижает бандл, TTI и нагрузку на слабые устройства.
  1. Возьми страницу каталога из pet-проекта: сделай её полностью серверной, а интерактивные элементы (кнопка «В корзину», переключатель избранного) — клиентскими островками в отдельных файлах. Замерь бандл до и после (next build показывает First Load JS).
  2. Найди в проекте компонент с useEffect, который догружает данные с /api. Перенеси загрузку в серверный компонент, оставив useEffect только если там реально клиентская логика (подписка/таймер).
  3. Намеренно передай клиентскому компоненту несериализуемый проп (функцию или Map), зафиксируй ошибку сериализации. Затем замени на Server Action и объясни разницу.
  4. Проведи аудит дерева: выписай все файлы с 'use client', проверь, не тянут ли они серверные модули (поиск по импортам lib/db, process.env без NEXT_PUBLIC). Задокументируй найденное.

Критерий результата: страница каталога рендерится на сервере с данными из БД, бандл содержит только островки, а дерево app/ визуально показывает, где серверная зона, а где клиентская.