Что можно передать через границу

серверные≈ 11 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

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

Разделы темы:·

В прошлой статье граница была отметкой в файле. Посмотрим на неё со стороны данных. Серверный компонент пишет <PriceTag amount={1200} />, а PriceTag помечен 'use client' — и обычного вызова функции тут не происходит. Серверное дерево внутрь клиентского компонента не заходит: оно записывает в поток «здесь стоит вот такой компонент, вот с такими пропсами» и идёт дальше. Выполнится PriceTag позже и уже не с вашими значениями, а с их копиями, восстановленными из этой записи.

«Записывает» — ключевое слово. Ближайшая знакомая вам операция такого рода — JSON.stringify из части I. Посмотрите на объект и решите, что от него останется после записи.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Функция исчезла молча: JSON.stringify просто пропускает свойство, в котором лежит функция. React в этом месте не молчит — он останавливает отрисовку и называет вам и пропс, и функцию в нём. Само сообщение разберём в третьем разделе.

Через JSON дата приезжает строкой, а Map вообще превращается в пустой объект. Формат, которым React передаёт пропсы, устроен подробнее: у него есть отдельная запись для даты, карты, множества и типизированного массива, и на другой стороне они восстанавливаются собой.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Граница односторонняя: пропсы едут с сервера в браузер. Обратно данные ходят другим способом — это глава про изменение данных.

Правило одно: проезжает то, что React умеет записать и восстановить на другой стороне. Список от этого получается закрытым и коротким.

Что передаёмПроедетПочему
Строки, числа, boolean, null, undefined, bigintдапримитив записывается целиком
Массивы и плоские объекты из проезжающих значенийдаReact обходит их вглубь, поле за полем
Date, Map, Set, типизированные массивыдау формата есть отдельная запись для каждого
Элемент JSX — <Stats />даэто обычный объект-описание; ему посвящён четвёртый раздел
Промисдаприедет незавершённым, значение дошлётся позже
Символ из общего реестра — Symbol.for("theme")даего можно построить заново по имени
Обычная функция, стрелка, обработчикнетфункция — это код и замыкание вокруг него, по проводу они не идут
Класс и любой его экземплярнетметоды лежат в прототипе, а прототип не восстановить
Свой символ — Symbol("theme")нетон уникален, второго такого не создать

«Плоский объект» — самая коварная строка таблицы. У экземпляра класса те же поля, что у литерала, и ни typeof, ни JSON их не различают. Прежде чем жать Run, решите, что напечатают три строки вывода.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Вот эту разницу React и проверяет, и говорит о ней прямо: Only plain objects, and a few built-ins, can be passed to Client Components from Server Components. Classes or null prototypes are not supported. Строка, пришедшая из базы через ORM, часто оказывается именно экземпляром класса — и чинится это одной строкой: собрать из неё литерал с нужными полями. Заодно перестанете отправлять в браузер лишние поля, о которых предупреждала первая статья главы.

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

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Проверка идёт вглубь. Массив плоских объектов, у одного из которых в поле лежит функция, не проедет: React дойдёт до неё и остановится.

Внутри одного бандла функция в пропсах — рутина: колбэк, форматтер, обработчик. И приезжает она не копией.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
true — и в этом всё дело. В пропсе лежала ссылка на конкретную функцию в памяти этого процесса. Через границу процессы разные, и ссылке там не на что указывать: отправить можно значение, а не адрес.

Тот же код, разложенный по двум файлам, у компилятора пройдёт, а у React остановится.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал next dev, при открытии страницы
⨯ Error: Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server". Or maybe you meant to call this function rather than return it.
<... amount={1200} format={function formatPrice}>
^^^^^^^^^^^^^^^^^^^^^^

Здесь листинг, а не запуск: ни Next, ни сервера в песочнице нет. Под кодом — сообщение из терминала next dev, записанное при открытии этой страницы; хвост со стеком опущен.

Читается сообщение по двум приметам. Строка <... amount={1200} format={function formatPrice}> — не ваш исходник, а восстановленное React описание элемента со всеми его пропсами. Имени компонента в ней нет, на его месте многоточие: в этом описании React подписывает теги вроде div, а компонент-функцию не называет. Зато каретка стоит под виноватым пропсом, а function formatPrice — имя вашей функции. По этим двум именам место находится за секунды даже в чужом коде.

Родственное сообщение приходит, когда функция попала не в пропс, а в дети: Functions are not valid as a child of Client Components. This may happen if you return Stats instead of <Stats /> from render. Чаще всего это опечатка — забыли угловые скобки, и в разметку поехал сам компонент вместо его элемента.

Зачем была нужна функцияКак чинить
Считала значение для показапосчитать на сервере и передать результат — строку, число, готовый массив
Была обработчиком событияперенести её в клиентский файл: там ей и место
Должна выполниться на сервере по нажатиюпометить её 'use server' — это серверное действие, тема десятой главы

Первая строка встречается чаще двух остальных вместе, и починка у неё скучная в хорошем смысле.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · консоль браузера, после нажатия на кнопку
в корзину: 1 200 ₽

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

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

Выход — не импорт, а дырка. Клиентский компонент объявляет children и ничего не знает о том, что туда положат; кладёт серверный родитель. Работает это потому, что children приезжает не функцией, а готовым элементом, — то есть данными, а данные через границу ходят.

Проверить это можно прямо здесь, безо всякого сервера: в обычном React children ведёт себя точно так же. Перед запуском решите, сколько раз напечатается «рендер Content» за три нажатия.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Один раз. Panel перерисовался трижды, а Content не вызвался больше ни разу: элемент <Content /> создал App, App не перерисовывался — и в Panel каждый раз приезжал тот же самый объект-элемент. Панель его не вызывает, она только ставит его в разметку.

На границе сервер — браузер это свойство становится главным приёмом главы. Panel помечена 'use client' и уехала в браузер. Stats остался на сервере, выполнился там и превратился в описание разметки. Сервер положил это описание в пропс children панели. Импорта из клиентского файла в серверный нет ни одного — оба элемента создаёт страница.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Слотом может быть любой пропс, не только children. Разница между «проедет» и «не проедет» здесь ровно та же, что во всей статье: элемент — это данные, функция — нет.

Что в пропсеПроедетЧто там лежит на самом деле
<Panel><Stats /></Panel>даэлемент в пропсе children
<Panel header={<Title />} />даэлемент в обычном пропсе
<Panel render={() => <Title />} />нетфункция, пусть и возвращающая элемент

Правило, экономящее часы: рамка клиентская, начинка серверная. Как только клиентский компонент начинает импортировать содержимое сам, вниз уезжает всё дерево.

Нельзя, и это не ограничение реализации, а следствие расписания. Содержимое приехало готовым ещё на сервере, где никакого состояния панели не существовало; подмешать в него значение из браузера панель уже не может — она получила разметку, а не рецепт её постройки.

Если содержимому нужно реагировать на нажатия в рамке, оно интерактивное, и место ему в клиентском файле. Серверным остаётся то, что от нажатий не меняется, — и обычно это как раз самая тяжёлая часть экрана: текст, таблица, список из базы.

Ошибка про функцию приходит поздно: компилятор молчит — для него пропс-функция совершенно нормальна, — и в первый раз вы видите её на сборке или в консоли dev-сервера. Часть III дала инструменты, чтобы сдвинуть проверку в редактор: рекурсивный тип и условный тип.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Диагностика приходит одна: тип объекта «не может быть назначен для типа never». never — тип без единого значения, поэтому положить туда нельзя вообще ничего; так условный тип и сообщает об отказе. Формулировка не самая приветливая, зато вы читаете её в редакторе, а не в терминале после сборки.

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

Типы исчезают при сборке, а значения приходят из базы, из внешнего API, из ORM — то есть оттуда, где типы написали вы сами и могли ошибиться. Про такие места была статья «Данные извне — это unknown»: там же говорилось, что тип — это обещание, а не проверка.

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

  • Пропсы клиентского компонента сервер записывает и отправляет: проезжает то, что можно записать и восстановить — примитивы, массивы, плоские объекты, Date, Map, Set, промисы и элементы JSX.
  • Функция не проезжает никогда, кроме одного случая: если она помечена 'use server' и стала серверным действием.
  • Экземпляр класса не проезжает, хотя typeof и JSON не отличают его от литерала: React смотрит на прототип.
  • Сообщение об ошибке приходит от React, а не от компилятора; в нём есть пропсы элемента, каретка под виноватым пропсом и имя вашей функции.
  • Серверная разметка попадает в клиентскую рамку через слот — children или обычный пропс с JSX; функция-рендер в этой роли не работает.
  • Компилятор границу не проверяет, но своим типом Serializable проверку можно приблизить к редактору.

1/4Серверный компонент передаёт клиентскому пропс user — экземпляр класса User с полями id, name и методом fullName(). Метод в браузере не нужен, нужны только поля. Что будет?

2/4Клиентская Panel объявляет children. Серверная страница пишет <Panel><Stats /></Panel>, где Stats — серверный компонент. Где выполнится Stats?

3/4В коде стоит <Panel render={() => <Stats />} />. Компилятор молчит. Что скажет отрисовка на сервере?

4/4Что из перечисленного не проедет через границу?

Убрать функцию из пропсов

Страница серверная, строка клиентская, а между ними едет formatAmount. Перепишите так, чтобы через границу не проезжала ни одна функция, а разметка на экране осталась прежней. Обработчик клика трогать не нужно — он и так живёт в клиентском файле. Последняя строка примера печатает разметку списка — после правки она должна выйти той же. Саму границу песочница не проверит: сервера в ней нет, оба «файла» выполняются в одной вкладке, так что эту половину сверьте с решением.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Форматирование — это вычисление, а вычисление можно сделать на сервере. Тогда через границу поедет строка, а Row перестанет знать про форматы вообще.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Пропс стал у́же, компонент — проще, а правила форматирования вместе с данными локали остались на сервере. Если бы форматов было несколько, через границу поехала бы не функция, а её имя: variant="money" — строка, и выбор по ней делает уже клиентский код.

Панель с дыркой

Клиентская панель импортирует содержимое сама — и утаскивает его в браузер вместе с собой. Переделайте её на слот: панель не должна знать, что внутри, а Stats должен остаться серверным. Разметка на экране не меняется.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Stats переезжает в свой файл — без директивы, то есть серверный, — а панель получает children и перестаёт что-либо знать о содержимом.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Импорт Stats исчез из клиентского файла и появился в серверном — этого и добивались. В браузер теперь уезжает только панель, а содержимое приезжает готовой разметкой, даже если завтра оно вырастет в таблицу на тысячу строк.

Строка из базы

Запрос вернул экземпляр класса — так делают многие ORM. Через границу он не проедет. Положите в forClient значение, которое проедет и сохранит id, title и createdAt; проверка в последней строке должна напечатать true.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Нужен литерал с перечисленными руками полями. Поля выбираются осознанно — это ровно тот момент, когда решается, что увидит посетитель.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

createdAt оставлять датой можно: Date через границу проходит. А метод isFresh в браузере не воскреснет никогда — если его результат нужен, он считается на сервере и едет отдельным полем: fresh: row.isFresh(). Обратите внимание, чего в литерале нет: всего остального, что вернул запрос.