Глава 9 Серверные компоненты React

Правила работы серверных компонентов

Книга
React. К вершинам мастерства

Fluent React: Build Fast, Performant, and Intuitive Web Applications

Глубокое погружение во внутреннее устройство React: JSX и продвинутые паттерны, виртуальный DOM и реконциляция, серверный рендеринг, конкурентный режим и серверные компоненты.

Глава 9 · Контекст

Кто где выполняется: шесть фактов

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

  • Серверные компоненты выполняются на сервере и выводят объекты — элементы React.
  • Клиентские компоненты тоже выполняются на сервере — при первом рендере, выводя такие же объекты.
  • На сервере живёт один большой объект со всеми элементами — из обоих типов компонентов.
  • Этот объект сериализуется в строку и отправляется клиенту.
  • С этого момента серверные компоненты никогда не выполняются на клиенте.
  • С этого момента клиентские компоненты выполняются только на клиенте.
Распространённое заблуждение: «клиентские компоненты выполняются только на клиенте». Нет — при серверном рендеринге они вызываются на сервере и приезжают в браузер уже как HTML.
Правило 1

Главное — сериализуемость

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

Так нельзя

// Серверный компонент
function ServerComponent() {
  return <ClientComponent onClick={() => alert("hi")} />;
}
// Ошибка: функция не сериализуется

Как обойти

Инкапсулировать onClick внутри самого клиентского компонента: обработчик рождается там, где он выполняется, — и через сеть его передавать не нужно.

Побочный эффект: умирает паттерн render props из главы 5 — когда компоненту вместо готовой разметки передают функцию, которая её рисует: <List renderItem={(item) => <li>{item.name}</li>} />. Пропс-функция не переживёт сериализацию — для серверных компонентов паттерн фактически устарел.
Правило 2

Отсутствие эффективных хуков

Серверная среда принципиально другая: она не интерактивна, у неё нет DOM и нет окна вывода. Поэтому хуки состояния и эффектов в серверных компонентах не поддерживаются.

Нельзя

useState, useReducer, useEffect, useRef, useContext — всё, что связано с состоянием, эффектами, рефами и браузером, живёт только на клиенте.

Можно

Только use и useId — плюс собственные хуки, собранные исключительно из них. После рендера серверный компонент не живёт в памяти — хукам просто не за что зацепиться.

Книга успела устареть

В книге: «useRef идеально подходит для RSC, а полный запрет — правило анализатора Next.js». Сегодня это правило самого React: серверная сборка react клиентские хуки не экспортирует.

Возможно, скудный выбор хуков — не так уж плохо: он склоняет нас к более безопасной работе с компонентами. Актуальный список — react.dev/reference/rsc/use-client.
Правило 3

Состояние — на самом деле не состояние

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

// SearchPage.jsx — серверный компонент
let lastQuery = ""; // переживает рендер = общая на процесс

export default async function SearchPage({ query }) {
  if (query) lastQuery = query; // «состояние»?
  return <Results q={lastQuery} />;
}

// Запрос Анны:   ?query=моя_зарплата
// Запрос Бориса: без query →
//   Борис видит, что искала Анна
  • На клиенте у каждого пользователя свой инстанс компонента в своём браузере — useState приватен по построению: один клиент — одно состояние.
  • На сервере один процесс по очереди обслуживает запросы всех. Всё, что живёт дольше одного рендера, — разделяемая память: запрос Бориса читает то, что записала Анна. Это и есть «утечка состояния между клиентами».
  • И чисто технически: setState — функция, а функции не сериализуются — диспетчер состояния даже нельзя отправить клиенту.
Поэтому useState и useReducer в серверных компонентах запрещены: всё, чему нужно состояние, — клиентский компонент. «А что, если очень хочется стейт на сервере?» — следующий слайд.
Правило 3 · А что, если всё-таки?

Стейт на сервере сделать можно. Почему не сделали?

Технически ничто не мешает: держи живой инстанс компонента в памяти на каждую вкладку и пуши ре-рендеры по вебсокету. Такие системы существуют — просто это другой продукт с другой ценой.

Как это выглядит в теории

Инстанс компонента живёт на сервере между рендерами + постоянный канал к клиенту для пуша обновлений + ответ на вопрос «чей это useState». Именно так работают Phoenix LiveView (Elixir) и Blazor Server (.NET) — компонентный стейт на сервере, каждое взаимодействие летает по вебсокету.

Цена

Каждый клик — сетевой round-trip; память сервера — на каждую открытую вкладку; sticky sessions и потеря стейта при падении сервера; обрыв связи — мёртвый UI; уборка брошенных сессий. Масштабирование меряется не запросами, а живыми соединениями.

Почему React отказался

LiveView вывозит рантайм BEAM — миллионы дешёвых изолированных процессов; у Node такого нет. Позиция React: RSC рассчитаны на stateless запрос → ответ, а состояние живёт там, где взаимодействие. Долгоживущее — это данные: БД/Redis → пропсы → server actions.

Кто всё же так делает: на чистом React — эксперименты с RSC по вебсокету (стейтфул-сервер шлёт JSX сообщениями). Прагматичный вариант в проде — sync-движки и реактивные бэкенды (Convex, ElectricSQL, Zero): стейт на сервере, пуш по вебсокету, но рендер остаётся клиентским.
Правило 4

Клиентский компонент не может импортировать серверный

Клиентские компоненты выполняются и на сервере, и в браузере. А серверный компонент может тянуть за собой то, чего в браузере просто нет.

Так нельзя

"use client";
import { ServerComponent } from "./ServerComponent";

function ClientComponent() {
  return (
    <div>
      <h1>Check out my server component!</h1>
      <ServerComponent />
    </div>
  );
}

Почему упадёт

import { readFile } from "node:fs/promises";

export async function ServerComponent() {
  const content = await readFile("./some-file.txt", "utf-8");
  return <div>{content}</div>;
}

В браузере нет node:fs/promises — импорт в клиентский код закончится ошибкой на клиенте.

Нюанс из актуального Next.js: компонент без «use client» — ещё не серверный, а универсальный. Если серверных зависимостей нет, импорт не упадёт — компонент молча станет клиентским и уедет в пакет. Ошибка — только когда ему в браузере нечем дышать, как ServerComponent выше. Железную гарантию даёт import "server-only" — с ним сборка упадёт сразу.
Правило 4 · Лазейка

…но может принять его через пропсы

Запрет касается только импорта: компоновщик смотрит на инструкции import, а не на состав пропсов. Композиция через children — легальна и идиоматична.

Клиентский компонент — «рамка»

"use client";

function ClientComponent({ children }) {
  return (
    <div>
      <h1>Check out my server component!</h1>
      {children}
    </div>
  );
}

Родитель-сервер собирает обоих

import { ServerComponent } from "./ServerComponent";

async function TheParentOfBoth() {
  return (
    <ClientComponent>
      <ServerComponent />
    </ClientComponent>
  );
}
Смысл запрета — не пустить серверный код в клиентский пакет. Передача готового серверного компонента пропсом в пакет ничего не добавляет — поэтому она разрешена.
Правило 5 · Ради чего всё это

Клиентские компоненты не так уж плохи

RSC появились не ради моды: в типичном SPA бо́льшая часть компонентов не интерактивна — шапки, статьи, списки, — но всё равно едет в пакет, гидрируется и требует отдельного API для данных. Серверные компоненты — дополнение, а не замена: идиоматика проста — всё серверное по умолчанию, «use client» вешаем на листья с интерактивом.

РаботаКомпонентПочему
Данные: БД, файлы, CMSСерверныйЧитаем там, где данные лежат, — без API-слоя и водопада «fetch → спиннер → fetch»
Секреты и токеныСерверныйКод не покидает сервер — утечь нечему
Статичная разметкаСерверныйНоль байт в JS-пакете, ноль гидратации
Состояние и обработчики событийКлиентскийuseState и onClick живут только в браузере
Эффекты и браузерные APIКлиентскийuseEffect, window, localStorage — на сервере их нет
Профит за всю эту сложность: в пакет уезжает только интерактив — быстрее загрузка, меньше работы процессору и сети, данные без лишнего API-слоя. Подробно о преимуществах — доклад 9.1 Антона.
Главные выводы

Стоит запомнить только это

  • Сериализуемость превыше всего: пропсы серверных компонентов едут по сети — функции и прочее несериализуемое не пройдёт.
  • Эффективных хуков нет: useState и useEffect — только на клиенте; useRef и хуки без состояния — можно.
  • Состояние сервера — общее для всех клиентов: нужен useState — значит, это клиентский компонент.
  • Импортировать серверный компонент из клиентского нельзя, а передать его через children — можно и нужно.
  • Клиентские компоненты никуда не уходят: RSC — дополнение к ним, а не замена.

Что далее

Преимущества Пройдено
Антон Помазков
Антон Помазков
Артём Никифоров
Артём Никифоров