Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
14.09.2026
Обновлено:
14.09.2026

Ошибки сериализации в React Server Components: как устроена граница 'use client' и как передавать данные без сбоев

Тимофей Ищенко

Коротко: Разбираем ошибки сериализации в React Server Components и Next.js App Router: барьер 'use client', белый список типов, передача Server Actions и DTO.

Переход на парадигму React Server Components (RSC) в Next.js App Router меняет привычную модель передачи данных между компонентами. В традиционных SPA или классическом SSR на Pages Router все дерево компонентов в конечном итоге собиралось в единый клиентский бандл. Любой объект, инстанс класса или функция-коллбек свободно передавались через props от родителя к потомку.

В архитектуре RSC компоненты разделены на две изолированные среды выполнения: серверную и клиентскую. Директива 'use client' служит не просто маркером интерактивности, а формирует сетевой барьер сериализации. Данные, пересекающие этот барьер, упаковываются в специальный поток (RSC Payload). Если структура данных не поддается сериализации, приложение падает с ошибкой во время рендеринга или сборки.

Разберем механику работы границы сериализации, типовые ошибки и архитектурные приемы, позволяющие организовать передачу данных без потери производительности.


Как работает граница сериализации в React Server Components

По умолчанию все компоненты в App Router являются серверными (RSC). Они выполняются исключительно на Node.js / Edge runtime, имеют прямой доступ к базам данных, файловой системе и приватным переменным окружения. В клиентский бандл их исходный код не попадает.

RSC Payload и сетевой барьер: что происходит при сборке ответа

Когда клиент запрашивает страницу или выполняет переход через роутер, сервер генерирует не только готовый HTML для первого кадра, но и поток данных в формате React Flight — RSC Payload.

Этот поток содержит:

  1. Отрендеренное дерево серверных компонентов (разметку).
  2. Плейсхолдеры (слоты) для клиентских компонентов.
  3. Сериализованные пропсы, которые сервер передает в клиентские компоненты для их последующей инициализации и гидратации.
+-------------------------------------------------------------+
|                      СЕРВЕР (Node.js)                       |
|  Server Component (Page / Layout)                           |
|         |                                                   |
|         v (вызов с пропсами)                                |
|  [ СЕТЕВОЙ БАРЬЕР: СЕРИАЛИЗАЦИЯ В RSC PAYLOAD (Flight) ]   |
+-------------------------------------------------------------+
                              |
                     Поток через сеть (HTTP)
                              |
+-------------------------------------------------------------+
|                      КЛИЕНТ (Браузер)                       |
|  [ ДЕСЕРИАЛИЗАЦИЯ И ГИДРАТАЦИЯ ]                            |
|         v                                                   |
|  Client Component ('use client')                            |
+-------------------------------------------------------------+

Любое значение, передаваемое из серверного контекста в пропсы клиентского компонента, должно пройти через механизм Flight-сериализации. Если значение невозможно представить в виде детерминированного потока байтов, процесс сборки RSC Payload прерывается исключением.

Модульный граф: почему 'use client' влияет на все импорты внутри файла

Директива 'use client' определяет точку входа (Entry Point) клиентской границы в графе модулей.

  • Все компоненты и утилиты, импортируемые в файл с 'use client', автоматически включаются в клиентский JavaScript-бандл.
  • Внутри клиентского поддерева компоненты могут свободно передавать друг другу несериализуемые данные (например, функции обратного вызова или хуки состояния), поскольку они выполняются в одной среде — браузере.
  • Требование сериализации распространяется только на пропсы, передаваемые в точке пересечения: когда серверный компонент рендерит клиентский.

Разрешенные и запрещенные типы данных

Спецификация React Flight шире, чем стандартный JSON.stringify, однако она накладывает строгие ограничения на типы.

Белый список сериализуемых структур

React из коробки умеет сериализовать следующие типы:

  • Примитивы: string, number, boolean, null, undefined, bigint.
  • Простые объекты и структуры: Plain Objects ({}), созданные через литералы или Object.create(null), а также массивы ([]).
  • Встроенные структуры данных: Date, Map, Set, FormData, URLSearchParams, типизированные массивы (Uint8Array, Float32Array и др.).
  • Асинхронные потоки: Promise (разрешается на клиенте с помощью хука use()).
  • Элементы разметки: JSX-элементы (дерево других серверных компонентов, переданное через children или слоты).
  • Server Actions: функции, явно помеченные директивой 'use server'.

Что приводит к сбоям сериализации

Попытка передать следующие типы данных через границу 'use client' вызовет ошибку:

  • Обычные функции и замыкания: onClick, onChange, обработчики событий и мутаторы состояния, объявленные в серверном компоненте.
  • Экземпляры пользовательских классов: инстансы ORM-моделей (Prisma, TypeORM, Drizzle), кастомные классы бизнес-логики, классы ошибок (Error).
  • Символы и мета-свойства: Symbol, объекты с циклическими ссылками, свойства, объявленные через Object.defineProperty с геттерами/сеттерами.

Типичные ошибки сериализации и способы их решения

На практике ошибки проявляются либо на этапе компиляции Next.js, либо в виде предупреждений в консоли разработчика во время гидратации.

1. Передача коллбека из серверного компонента

Распространенная ошибка при миграции с Pages Router — попытка объявить функцию-обработчик в серверном компоненте и передать ее в интерактивную кнопку.

Ошибка:

// app/users/page.tsx (Server Component по умолчанию)
import { DeleteButton } from './DeleteButton';

export default async function UsersPage() {
  // ОШИБКА: Функция объявлена на сервере и не может быть передана через границу
  const handleDelete = () => {
    console.log('Пользователь удален');
  };

  return <DeleteButton onDelete={handleDelete} />;
}
// app/users/DeleteButton.tsx
'use client';

interface DeleteButtonProps {
  onDelete: () => void;
}

export function DeleteButton({ onDelete }: DeleteButtonProps) {
  return <button onClick={onDelete}>Удалить</button>;
}

Решение через Server Action:

Если функция выполняет мутацию на сервере, ее необходимо пометить как 'use server'. В таком случае React не сериализует код функции, а генерирует уникальный сетевой эндпоинт (RPC-идентификатор).

// app/users/actions.ts
'use server';

export async function deleteUserAction(userId: string): Promise<void> {
  // Логика работы с базой данных
  await db.user.delete({ where: { id: userId } });
}
// app/users/DeleteButton.tsx
'use client';

import { useTransition } from 'react';
import { deleteUserAction } from './actions';

interface DeleteButtonProps {
  userId: string;
}

export function DeleteButton({ userId }: DeleteButtonProps) {
  const [isPending, startTransition] = useTransition();

  return (
    <button
      disabled={isPending}
      onClick={() => startTransition(() => deleteUserAction(userId))}
    >
      {isPending ? 'Удаление...' : 'Удалить'}
    </button>
  );
}

2. Передача экземпляров классов и моделей ORM

Когда серверный компонент запрашивает данные из базы данных через ORM (например, Prisma), возвращаемые объекты могут содержать скрытые методы прототипов или свойства, не являющиеся простыми объектами.

Ошибка:

// app/profile/page.tsx (Server Component)
import { UserProfileCard } from './UserProfileCard';
import { userService } from '@/services/userService';

export default async function ProfilePage() {
  // userService.getUser возвращает экземпляр класса UserEntity с методами
  const userEntity = await userService.getUser('123');

  // ОШИБКА: Only plain objects can be passed to Client Components from Server Components.
  // Objects with toJSON methods are not supported.
  return <UserProfileCard user={userEntity} />;
}

Решение через нормализацию данных (DTO):

Перед отправкой данных на клиент инстанс класса преобразуется в плоский объект (Data Transfer Object).

// app/profile/page.tsx (Server Component)
import { UserProfileCard } from './UserProfileCard';
import { userService } from '@/services/userService';

interface UserDTO {
  id: string;
  name: string;
  email: string;
  registeredAt: string;
}

export default async function ProfilePage() {
  const userEntity = await userService.getUser('123');

  // Преобразование сущности в чистый DTO
  const userDTO: UserDTO = {
    id: userEntity.id,
    name: userEntity.name,
    email: userEntity.email,
    registeredAt: userEntity.createdAt.toISOString(),
  };

  return <UserProfileCard user={userDTO} />;
}

3. Избыточная директива 'use client' на дочерних компонентах

Разработчики часто добавляют 'use client' в начало каждого файла в папке с компонентами формы или интерфейса. Если клиентский компонент рендерит другой компонент с 'use client' и передает ему функции (например, геттеры состояния useState), сборщик Next.js может выдать предупреждение:

Props must be serializable for components in the "use client" entry file...

Причина:

Директива 'use client' объявляет модуль точкой входа границы. Если компонент гарантированно используется только внутри другого клиентского компонента, директива 'use client' для него избыточна.

Решение:

Удалите 'use client' из внутренних низкоуровневых компонентов. Они останутся частью клиентского бандла благодаря импорту в родительский клиентский компонент, но перестанут рассматриваться сборщиком как потенциальные точки входа со стороны сервера.


Архитектурные паттерны обхода ограничений сериализации

Чтобы минимизировать объем передаваемых данных и избежать ошибок сериализации, применяются проверенные архитектурные паттерны.

Паттерн композиции: использование children и JSX-слотов

Если интерактивному компоненту (модальному окну, аккордеону, боковой панели) требуется отобразить тяжелое серверное поддерево, нет необходимости делать всё поддерево клиентским. Серверный компонент можно передать в качестве пропа children.

// components/Modal.tsx
'use client';

import { useState, type ReactNode } from 'react';

interface ModalProps {
  children: ReactNode; // JSX-дерево сериализуется как виртуальные узлы RSC
}

export function Modal({ children }: ModalProps) {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setIsOpen(true)}>Открыть окно</button>
      {isOpen && <div className="modal-content">{children}</div>}
    </div>
  );
}
// app/dashboard/page.tsx (Server Component)
import { Modal } from '@/components/Modal';
import { ServerDataList } from '@/components/ServerDataList'; // Тяжелый RSC с прямым доступом к БД

export default function DashboardPage() {
  return (
    <main>
      <h1>Панель управления</h1>
      <Modal>
        {/* ServerDataList выполнится на сервере, его JS-код не попадет в бандл клиента */}
        <ServerDataList />
      </Modal>
    </main>
  );
}

При такой композиции ServerDataList рендерится на сервере, преобразуется в структуру виртуальных узлов и передается в Modal как готовый JSX-результат. Никаких нарушений сериализации функций или данных не происходит.

Передача потоков через Promise и хук use()

Серверные компоненты могут передавать незавершенные промисы напрямую в клиентские компоненты, не блокируя начальный рендеринг страницы.

// app/analytics/page.tsx (Server Component)
import { Suspense } from 'react';
import { AnalyticsChart } from './AnalyticsChart';

async function fetchStats(): Promise<{ views: number; clicks: number }> {
  // Запрос к аналитическому сервису
  const res = await fetch('https://api.internal/stats');
  return res.json();
}

export default function AnalyticsPage() {
  // Запускаем Promise, но НЕ используем await на верхнем уровне
  const statsPromise = fetchStats();

  return (
    <div>
      <h2>Статистика переходов</h2>
      <Suspense fallback={<p>Загрузка аналитики...</p>}>
        <AnalyticsChart dataPromise={statsPromise} />
      </Suspense>
    </div>
  );
}
// app/analytics/AnalyticsChart.tsx
'use client';

import { use } from 'react';

interface AnalyticsChartProps {
  dataPromise: Promise<{ views: number; clicks: number }>;
}

export function AnalyticsChart({ dataPromise }: AnalyticsChartProps) {
  // use() разворачивает Promise прямо в процессе рендеринга клиента
  const stats = use(dataPromise);

  return (
    <div>
      <p>Просмотры: {stats.views}</p>
      <p>Клики: {stats.clicks}</p>
    </div>
  );
}

Диагностика и профилактика техдолга при проектировании RSC

При разработке интерфейсов на базе Next.js и React Server Components соблюдайте следующие правила организации архитектуры:

  1. Стратегия «Листьев дерева» (Leaf Components): Смещайте директиву 'use client' как можно ближе к конечным узлам дерева компонентов. Корневые лейауты, страницы и агрегаторы данных должны оставаться серверными. Клиентскими должны быть только кнопки, поля ввода, дропдауны и контекстные провайдеры.

  2. Строгая типизация DTO: Создавайте отдельные интерфейсы для данных, пересекающих сетевой барьер. Не используйте типы моделей базы данных (Prisma types) напрямую в клиентских компонентах.

  3. Изоляция утилит: Если библиотека работает с Node.js API (например, fs, crypto, child_process), добавьте в файл маркер import 'server-only'. Это гарантирует, что случайный импорт модуля в клиентский компонент вызовет ошибку на этапе сборки, а не во время исполнения у пользователя.


FAQ: частые вопросы разработчиков

Можно ли передать Date или Set через пропсы в клиентский компонент без ручного преобразования в JSON?

Да. Протокол сериализации React Flight поддерживает нативные объекты Date, Map, Set, BigInt и FormData. Они автоматически преобразуются в поток на сервере и восстанавливаются до соответствующих инстансов в браузере.

Почему нельзя объявить обычную функцию внутри Server Component и передать её в onClick клиентской кнопки?

Функция JavaScript в памяти процесса Node.js содержит ссылку на контекст исполнения, лексическое окружение и замыкания. Передать исполняемый машинный код через сетевой поток невозможно. Для вызова серверной логики с клиента необходимо использовать Server Actions ('use server').

Нужно ли ставить 'use client' в каждом файле папки UI-компонентов?

Нет. Директиву следует указывать только в тех файлах, которые являются точками входа клиентской границы (импортируются напрямую в серверные компоненты). Если компонент (например, Icon или Typography) импортируется только внутрь других клиентских компонентов, директива 'use client' не требуется.

Чем Server Actions отличаются от обычных функций при передаче через границу?

Обычная функция — это блок JavaScript-кода в памяти сервера. Server Action — это задекларированный RPC-эндпоинт (Remote Procedure Call). При сборке Next.js заменяет тело Server Action на уникальный идентификатор (хеш). Клиентский компонент получает не сам код функции, а сетевую ссылку для выполнения POST-запроса на сервер.

Как передать серверный компонент с асинхронной загрузкой данных внутрь клиентского таб-контейнера?

Используйте паттерн композиции через проп children или именованные слоты (slotLeft={<ServerComponent />}). Клиентский контейнер будет управлять состоянием переключения вкладок, а серверный компонент выполнит запрос к данным на сервере и передаст в слот готовый JSX.


Заключение

Граница 'use client' в React Server Components — это архитектурный водораздел между средой выполнения сервера и браузера. Ошибки сериализации указывают на попытку нарушить сетевую изоляцию сред.

Для построения масштабируемых и стабильных приложений на Next.js App Router придерживайтесь трех базовых правил:

  • Трансформируйте сложные сущности и инстансы классов в чистые DTO перед передачей клиенту.
  • Используйте паттерн композиции (children и JSX-слоты), чтобы изолировать интерактивные клиентские оболочки от серверной логики загрузки данных.
  • Заменяйте передачу серверных функций-коллбеков на Server Actions или локальные обработчики событий внутри клиентских компонентов.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также