'use client' и сериализуемые пропсы
'use client' помечает модуль и всё его поддерево импортов как клиентское; пропсы Server→Client должны быть сериализуемыми, поэтому данные текут вниз как сериализуемые пропсы, а серверный контент составляется в клиентские оболочки через children — а не импортом через границу.
Первое, что все делают неправильно с React Server Components, — тянутся за 'use client' как за приправой: бросают её на тот единственный файл, которому нужен useState, выкатывают, идут дальше. Потом всплывает ошибка Cannot pass a function from a Server to a Client Component, или твой «маленький клиентский островок» тихо утаскивает треть бандла в браузер, — и директива перестаёт ощущаться однострочником.
'use client' — это не переключатель на уровне компонента. Это маркер границы на модуле, и у него два следствия, которые большинство усваивает на собственных шишках: всё, что этот модуль импортирует, тоже становится клиентским кодом, и всё, что пересекает его как проп, обязано быть сериализуемым. Усвой эти два правила — и вся модель Server/Client перестаёт быть загадкой.
После этого урока ты можешь точно сказать, что помечает 'use client' (модуль и всё его поддерево импортов, а не один компонент); перечислить, что может и что не может пересечь границу пропсов Server→Client (сериализуемые данные — да; функции, экземпляры классов и объекты-носители поведения — нет); объяснить, почему составление Server-компонента внутрь Client-компонента через children работает, а импорт его через границу — нет; и распознавать два канонических режима отказа — несериализуемый проп и недопустимый импорт через границу — с первого взгляда.
'use client' помечает модуль — и всё, что он импортирует, — как границу клиента, а не один компонент. Директива стоит в начале файла. С этого файла вниз по его графу импортов код выполняется (и грузится) на клиенте. Server-компонент, который рендерит 'use client'-компонент, — родитель границы; задача директивы — сказать бандлеру «отсюда вниз это поддерево клиентское». Так что единица, которую ты помечаешь, — никогда не «этот один компонент», а «этот модуль и всё, до чего из него можно дотянуться».
// likes-button.tsx
'use client';
import { useState } from 'react';
import { formatCount } from './format'; // <- этот модуль теперь тоже КЛИЕНТСКИЙ
export function LikesButton({ initial }: { initial: number }) {
const [n, setN] = useState(initial);
return <button onClick={() => setN(n + 1)}>{formatCount(n)} likes</button>;
}У formatCount не было собственной директивы, но импорт из клиентского модуля утаскивает его через границу. Практическое senior-следствие: 'use client'-файл на вершине глубокого дерева импортов затягивает всё это дерево в клиентский бандл. Ставь директиву на лист, которому реально нужна интерактивность, а не на layout, импортирующий половину приложения.
Пропсы, передаваемые из Server-компонента в Client-компонент, должны быть сериализуемыми. Граница — это настоящий провод: сервер рендерит, и пропсы для каждого клиентского островка сериализуются в payload, из которого браузер гидратируется. Сериализуемое — это то, что RSC умеет закодировать: примитивы, простые объекты и массивы сериализуемых значений, Date, Map, Set, ссылки на server-actions, JSX (ReactNode) и Promises сериализуемых значений. Несериализуемое: произвольные функции, экземпляры классов и любой объект, который ты передаёшь ради его поведения, а не данных.
// page.tsx (Server-компонент — без директивы)
import { LikesButton } from './likes-button';
export default async function Page() {
const post = await db.post.find(); // экземпляр класса? -> отобрази в простые данные
return (
<LikesButton
initial={post.likes} // число — нормально
// onLiked={() => track('like')} // функция — НЕ может пересечь; бросит ошибку
/>
);
}Почему «никаких функций»? Функция — это непрозрачное поведение; сервер не может переслать замыкание в браузер так, чтобы оно значило то же самое. Благословенное исключение — server action ('use server'), который передаётся как ссылка, которую клиент может вызвать, а не как живое замыкание. Так что коллбэки пересекают границу как server actions, а не как простые функции.
Серверный контент ты составляешь внутрь Client-компонента через children (или любой ReactNode-слот) — ты не import-ируешь Server-компонент в 'use client'-файл. Вот этот ход и сбивает людей с толку. Client-компонент не может импортировать Server-компонент (правило графа импортов из шага 1 превратило бы Server-компонент в клиентский код, лишив его серверных способностей вроде прямого доступа к БД). Но он может рендерить серверно-отрендеренный контент, переданный ему как проп. Server-компонент остаётся на сервере; его уже отрендеренный вывод передаётся вниз как children, а клиентская оболочка просто вставляет его в слот.
// клиентская оболочка — владеет интерактивностью (открыть/закрыть), ничего не знает о своём контенте
'use client';
import { useState } from 'react';
export function Collapsible({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<section>
<button onClick={() => setOpen(o => !o)}>{open ? 'Hide' : 'Show'}</button>
{open && children}
</section>
);
}// серверная страница — составляет SERVER-компонент в клиентскую оболочку как children
import { Collapsible } from './collapsible';
import { ServerReport } from './server-report'; // Server-компонент (доступ к БД и т.п.)
export default function Page() {
return (
<Collapsible>
<ServerReport /> {/* отрендерен на сервере; передан внутрь как children */}
</Collapsible>
);
}Collapsible никогда не импортирует ServerReport. Родитель (Server-компонент) импортирует оба и связывает их вместе. Это и есть правило «составляй, а не импортируй через границу»: серверный контент течёт внутрь клиентских компонентов как children, а не через границу импортом.
Два режима отказа, названные так, чтобы ты замечал их с первого взгляда. Отказ A: передача несериализуемого пропа через границу — функции, экземпляра класса, живого объекта, переданного ради поведения. Ты увидишь Functions are not valid as a child of Client Components или Only plain objects ... can be passed. Лечение — передавать данные (сериализовать экземпляр в простой объект) и передавать коллбэки как server actions. Отказ B: импорт Server-компонента в 'use client'-модуль — по правилу из шага 1 это перетегирует Server-компонент в клиентский, так что его серверный код (клиенты БД, секреты, fs) либо ломает сборку, либо, хуже, утекает в сторону браузера. Лечение — инвертировать: не импортируй его, а получи как children от серверного родителя.
// ❌ Отказ B: Server-компонент импортирован в клиентский файл
'use client';
import { ServerReport } from './server-report'; // теперь клиентский; доступ к БД ломается
export function Panel() { return <div><ServerReport /></div>; }
// ✅ Лечение: прими его как children, пусть серверный родитель его подаст
'use client';
export function Panel({ children }: { children: React.ReactNode }) {
return <div>{children}</div>;
}Когда границу не стоит проводить вообще: если у поддерева нет интерактивности, оставь его как Server-компоненты — без директивы, без налога на сериализацию, без клиентских байтов. 'use client' — это цена (сериализация + бандл), так что ставь её на наименьший лист, которому действительно нужны браузерные API или состояние.
Отрефактори «клиентскую модалку, которой нужны серверные данные» из сломанной в корректную. Страница товара хочет модалку (клиент: состояние открыть/закрыть), показывающую серверно-загруженный лист характеристик (сервер: доступ к БД). Инстинкт — импортировать загружающий данные компонент в модалку, — а это в точности Отказ B.
До — Client-компонент импортирует Server-компонент и пытается передать коллбэк через границу:
// spec-modal.tsx
'use client';
import { useState } from 'react';
import { SpecSheet } from './spec-sheet'; // ❌ Server-компонент импортирован -> становится клиентским
export function SpecModal({ onOpen }: { onOpen: () => void }) { // ❌ функция-проп пересекает границу
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => { setOpen(true); onOpen(); }}>Specs</button>
{open && <div className="modal"><SpecSheet /></div>} {/* SpecSheet теперь шлёт свой код БД в клиент */}
</>
);
}Два нарушения границы: SpecSheet (серверный доступ к БД) импортирован в клиентский модуль, а onOpen — живая функция, переданная от серверного родителя. После — инвертируй оба. Клиентская оболочка принимает серверный контент как children, а коллбэк становится server action:
// spec-modal.tsx
'use client';
import { useState } from 'react';
export function SpecModal({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Specs</button>
{open && <div className="modal">{children}</div>}
</>
);
}// page.tsx (Server-компонент)
import { SpecModal } from './spec-modal';
import { SpecSheet } from './spec-sheet'; // остаётся на сервере
import { logSpecsOpened } from './actions'; // 'use server' action
export default async function Page({ id }: { id: string }) {
return (
<SpecModal>
<SpecSheet productId={id} /> {/* отрендерен на сервере, передан как children */}
</SpecModal>
);
}SpecModal ничего не знает о характеристиках или базе данных — она владеет только открытием/закрытием. SpecSheet сохраняет серверные способности, потому что его никогда не импортируют через границу; серверный родитель рендерит его и передаёт вывод вниз как children. Если бы тебе нужно было отследить событие открытия, logSpecsOpened пересекает границу как server action, а не как простая функция. Сериализуемые данные (productId, строка) — единственное, что путешествует вниз как проп.
▸Почему это работает
Почему составление через children обходит правило импорта? Потому что к тому моменту, как children достигает Client-компонента, Server-компонент уже отрендерился на сервере — клиент получает его сериализованный вывод (ReactNode), а не исходный модуль компонента. Клиентская оболочка держит дырку, и React вставляет серверно-отрендеренный контент в неё во время реконсиляции. Код Server-компонента никогда не попадает в граф импортов клиента, так что его серверные зависимости (клиенты БД, секреты) никогда не помечаются для браузера. «Передавай серверный вывод вниз, не тяни серверный исходник через границу» — вот и весь трюк.
▸Частая ошибка
Самая коварная версия бага несериализуемого пропа — это экземпляр класса, который выглядит как данные. Ты загружаешь строку через ORM и передаёшь объект-модель прямо в Client-компонент; в dev работает, потому что значения на месте, потом ломается, потому что экземпляр несёт методы/прототип, которые не сериализуются, — или их молча обрезает, и твой клиентский код вызывает теперь-отсутствующий метод. Передавай простой объект ({ id, name, price }), а не живую модель. Та же ловушка с Date, обёрнутым в класс, или с config-объектом, весь смысл которого в его методах. Если ты передаёшь его ради поведения, ему не место через границу — передавай данные, а поведение помести на клиентскую сторону или за server action.
Client-компоненту (оболочке модалки с состоянием открыть/закрыть) нужно показать секцию, отрендеренную Server-компонентом, который читает из базы данных. Как их правильно объединить?
'use client' — это маркер границы модуля, а не переключатель на уровне компонента: он помечает файл и всё его поддерево импортов как клиентское, так что директива, поставленная слишком высоко, утаскивает целое дерево в браузерный бандл — ставь её на наименьший интерактивный лист. Через эту границу данные текут вниз как сериализуемые пропсы — примитивы, простые объекты/массивы, Date, Map/Set, JSX, ссылки на server-actions, — тогда как функции, экземпляры классов и объекты-носители поведения отвергаются; коллбэки пересекают границу как server actions, а не как живые замыкания. И ты никогда не import-ируешь Server-компонент в 'use client'-файл (это перетегировало бы его в клиентский и сломало его серверные способности); вместо этого ты составляешь серверный контент в клиентские оболочки через children, где серверный родитель рендерит Server-компонент и передаёт его вывод вниз как слот. Два режима отказа — несериализуемый проп и недопустимый импорт через границу — оба решаются одинаково: передавай данные вниз и передавай серверный контент внутрь как children. Помечай лист, сериализуй данные, составляй остальное.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.