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’: как она устроена
Заголовок раздела «Граница ‘use client’: как она устроена»Директива '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), либо клиентский колбэк, определённый в клиентском файле.
Что нельзя в RSC
Заголовок раздела «Что нельзя в RSC»Серверный компонент — это не «компонент со суперсилойми», а компонент с ограничениями. Нельзя:
- хуки состояния и эффектов:
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). Это накладывает следствия, которые в продакшене бьют регулярно:
- Тяжёлые объекты — лишний вес HTML. Передал весь объект пользователя с 30 полями вместо трёх нужных — каждый байт уедет в каждый RSC payload каждому пользователю. Дерево пропсов на границе — точка оптимизации: мапь данные до передачи.
- Классы теряют методы.
new Date()дойдёт как строка/Date, но твой class User с методами.fullName()превратится в пlain object. Маппинг на DTO — обязателен. - Циклические ссылки рвут сериализацию. 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 — максимум серверного дерева, минимум клиентского. Практические приёмы:
- Директиву ставь максимально ниже. Форма с одним
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> );}- Серверные данные — в серверные компоненты, клиенту — минимум. Таблицу с сортировкой можно сделать серверной (сортировка через searchParams и серверный рендер), а клиентским оставить только выпадашку фильтров.
- Контекст — либо корень клиентского поддерева, либо composable-обёртки. AuthProvider на клиенте означает, что всё внутри — клиентское. Часто лучше: серверная страница читает сессию и раздаёт готовые данные островкам.
- Тяжёлые библиотеки ниже границы. Chart.js, монако-редактор, карты — оборачивай в клиентские компоненты и грузи лениво (
next/dynamic), чтобы серверная часть страницы не ждала их бандл.
Результат правильного разбиения: бандл приложения на сотни килобайт меньше, TTI на слабых устройствах — на секунды быстрее, а секреты и тяжёлые запросы остались на сервере, где им место.
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»-
'use client'в корне приложения. Поставил директиву в корневой layout «на всякий случай» — весь сайт стал клиентским, RSC-смысл потерян, бандл — размером с классический SPA. Плохо: директива вверху дерева. Хорошо: островки внизу. -
Импорт серверного модуля из клиентского.
import { db } from '@/lib/db'в файле с'use client'— либо ошибка сборки (Next.js блокирует серверные модули), либо молчаливая утечка: модуль попал в бандл вместе с env-подстановками. Правило: серверные модули импортируются только из серверных файлов. -
Несериализуемые пропсы через границу. Передача
Map, функции-колбэка или class-инстанса клиентскому компоненту ломает сериализацию с загадочной ошибкой. Маппи всё в plain objects, действия — через Server Actions. -
Передача лишних данных «про запас». Весь объект юзера с хешем пароля и телефоном — в пропсы клиентской карточки. Помимо веса payload это потенциальная утечка PII в клиентский бандл-лог. Плохо:
user={user}. Хорошо: явный whitelist полей. -
window/localStorage в «нейтральном» компоненте. Утилита, импортируемая и серверным, и клиентским компонентом, обратилась к
localStorage— падает SSR с «window is not defined». Решения: гардtypeof window !== 'undefined', перенос в клиентский файл, либоuseEffect(не выполняется на сервере). -
useEffect как способ «догрузить серверные данные». Классическая привычка из SPA: страница-скелетон +
useEffect(fetch). В RSC это антипаттерн: данные надёжнее, быстрее и SEO-дружелюбнее грузить прямо в серверном компоненте.useEffectоставь для чисто клиентских вещей: подписки, таймеры, DOM-интеграции.
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»- Что такое React Server Component и чем он отличается от SSR? SSR генерирует HTML всего дерева на сервере для первой загрузки; RSC — архитектура, при которой часть компонентов исполняется только на сервере и не попадает в JS-бандл вовсе. RSC может сочетаться со статикой, ISR и streaming.
- Почему нельзя useState в серверном компоненте? Потому что серверный компонент исполняется один раз на сервере и не имеет жизненного цикла в браузере: состояние, эффекты и события не имеют к нему смысла. Интерактив — удел клиентских компонентов.
- Какие пропсы можно передать из серверного компонента в клиентский? Только сериализуемые: plain объекты, массивы, строки, числа, boolean, null, Date, Promise. Функции — только Server Actions. Map/Set/class-инстансы — нельзя.
- Что такое гидратация и почему она дорогая? Процесс сопоставления клиентского JS с уже отрисованным HTML и навешивания интерактивности. Дорога, потому что требует скачивания и исполнения JS на главном потоке, конкурируя с вводом пользователя.
- Как определить, где проходит граница сервер/клиент в проекте? По файлам с
'use client': всё, что импортируется из такого файла (транзитивно), — клиентское. Всё остальное — серверное. Ошибка «server-only module in client bundle» укажет нарушение. - Как передать данные из клиентского компонента на сервер? Только через Server Action (вызов серверной функции из клиента) или через router/navigation с последующей серверной обработкой. Напрямую «вверх по границе» данные не передаются.
- Паттерн «островков» — в чём суть? Большая серверная часть страницы (данные, разметка, SEO) + минимальные клиентские компоненты для интерактивных зон. Снижает бандл, TTI и нагрузку на слабые устройства.
Практика
Заголовок раздела «Практика»- Возьми страницу каталога из pet-проекта: сделай её полностью серверной, а интерактивные элементы (кнопка «В корзину», переключатель избранного) — клиентскими островками в отдельных файлах. Замерь бандл до и после (
next buildпоказывает First Load JS). - Найди в проекте компонент с
useEffect, который догружает данные с/api. Перенеси загрузку в серверный компонент, оставивuseEffectтолько если там реально клиентская логика (подписка/таймер). - Намеренно передай клиентскому компоненту несериализуемый проп (функцию или Map), зафиксируй ошибку сериализации. Затем замени на Server Action и объясни разницу.
- Проведи аудит дерева: выписай все файлы с
'use client', проверь, не тянут ли они серверные модули (поиск по импортамlib/db,process.envбез NEXT_PUBLIC). Задокументируй найденное.
Критерий результата: страница каталога рендерится на сервере с данными из БД, бандл содержит только островки, а дерево app/ визуально показывает, где серверная зона, а где клиентская.