Что можно передать через границу
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Граница между сервером и браузером — не вызов функции, а посылка. Пропсы клиентского компонента сервер записывает, отправляет по проводу и собирает заново уже в браузере, поэтому проехать может только то, что записывается. Функция не записывается: она живёт в памяти того процесса, где родилась.
В прошлой статье граница была отметкой в файле. Посмотрим на неё со стороны данных. Серверный компонент пишет <PriceTag amount={1200} />, а PriceTag помечен 'use client' — и обычного вызова функции тут не происходит. Серверное дерево внутрь клиентского компонента не заходит: оно записывает в поток «здесь стоит вот такой компонент, вот с такими пропсами» и идёт дальше. Выполнится PriceTag позже и уже не с вашими значениями, а с их копиями, восстановленными из этой записи.
«Записывает» — ключевое слово. Ближайшая знакомая вам операция такого рода — JSON.stringify из части I. Посмотрите на объект и решите, что от него останется после записи.
Функция исчезла молча: JSON.stringify просто пропускает свойство, в котором лежит функция. React в этом месте не молчит — он останавливает отрисовку и называет вам и пропс, и функцию в нём. Само сообщение разберём в третьем разделе.
Через JSON дата приезжает строкой, а Map вообще превращается в пустой объект. Формат, которым React передаёт пропсы, устроен подробнее: у него есть отдельная запись для даты, карты, множества и типизированного массива, и на другой стороне они восстанавливаются собой.
Граница односторонняя: пропсы едут с сервера в браузер. Обратно данные ходят другим способом — это глава про изменение данных.
Правило одно: проезжает то, что React умеет записать и восстановить на другой стороне. Список от этого получается закрытым и коротким.
| Что передаём | Проедет | Почему |
|---|---|---|
Строки, числа, boolean, null, undefined, bigint | да | примитив записывается целиком |
| Массивы и плоские объекты из проезжающих значений | да | React обходит их вглубь, поле за полем |
Date, Map, Set, типизированные массивы | да | у формата есть отдельная запись для каждого |
Элемент JSX — <Stats /> | да | это обычный объект-описание; ему посвящён четвёртый раздел |
| Промис | да | приедет незавершённым, значение дошлётся позже |
Символ из общего реестра — Symbol.for("theme") | да | его можно построить заново по имени |
| Обычная функция, стрелка, обработчик | нет | функция — это код и замыкание вокруг него, по проводу они не идут |
| Класс и любой его экземпляр | нет | методы лежат в прототипе, а прототип не восстановить |
Свой символ — Symbol("theme") | нет | он уникален, второго такого не создать |
«Плоский объект» — самая коварная строка таблицы. У экземпляра класса те же поля, что у литерала, и ни typeof, ни JSON их не различают. Прежде чем жать Run, решите, что напечатают три строки вывода.
Вот эту разницу 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, часто оказывается именно экземпляром класса — и чинится это одной строкой: собрать из неё литерал с нужными полями. Заодно перестанете отправлять в браузер лишние поля, о которых предупреждала первая статья главы.
Символы — исключение внутри исключения, и правило у них ровно то же: проедет тот, кого можно построить заново.
Проверка идёт вглубь. Массив плоских объектов, у одного из которых в поле лежит функция, не проедет: React дойдёт до неё и остановится.
Внутри одного бандла функция в пропсах — рутина: колбэк, форматтер, обработчик. И приезжает она не копией.
true — и в этом всё дело. В пропсе лежала ссылка на конкретную функцию в памяти этого процесса. Через границу процессы разные, и ссылке там не на что указывать: отправить можно значение, а не адрес.Тот же код, разложенный по двум файлам, у компилятора пройдёт, а у React остановится.
Здесь листинг, а не запуск: ни 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' — это серверное действие, тема десятой главы |
Первая строка встречается чаще двух остальных вместе, и починка у неё скучная в хорошем смысле.
Заодно исчез повод везти в браузер правила форматирования и данные локали: их применили там, где они уже были. Это та же арифметика, что в первой статье главы, — только теперь вы её делаете осознанно.
Из прошлой статьи вы помните: клиентский файл забирает с собой всё, что импортирует. Значит, импортировать серверный компонент из клиентского нельзя — он тут же перестанет быть серверным. А ставить серверную разметку внутрь клиентской рамки хочется постоянно: вкладки, аккордеон, модальное окно, переключатель темы — рамка интерактивная, содержимое неподвижное.
Выход — не импорт, а дырка. Клиентский компонент объявляет children и ничего не знает о том, что туда положат; кладёт серверный родитель. Работает это потому, что children приезжает не функцией, а готовым элементом, — то есть данными, а данные через границу ходят.
Проверить это можно прямо здесь, безо всякого сервера: в обычном React children ведёт себя точно так же. Перед запуском решите, сколько раз напечатается «рендер Content» за три нажатия.
Один раз. Panel перерисовался трижды, а Content не вызвался больше ни разу: элемент <Content /> создал App, App не перерисовывался — и в Panel каждый раз приезжал тот же самый объект-элемент. Панель его не вызывает, она только ставит его в разметку.
На границе сервер — браузер это свойство становится главным приёмом главы. Panel помечена 'use client' и уехала в браузер. Stats остался на сервере, выполнился там и превратился в описание разметки. Сервер положил это описание в пропс children панели. Импорта из клиентского файла в серверный нет ни одного — оба элемента создаёт страница.
Слотом может быть любой пропс, не только children. Разница между «проедет» и «не проедет» здесь ровно та же, что во всей статье: элемент — это данные, функция — нет.
| Что в пропсе | Проедет | Что там лежит на самом деле |
|---|---|---|
<Panel><Stats /></Panel> | да | элемент в пропсе children |
<Panel header={<Title />} /> | да | элемент в обычном пропсе |
<Panel render={() => <Title />} /> | нет | функция, пусть и возвращающая элемент |
Правило, экономящее часы: рамка клиентская, начинка серверная. Как только клиентский компонент начинает импортировать содержимое сам, вниз уезжает всё дерево.
Нельзя, и это не ограничение реализации, а следствие расписания. Содержимое приехало готовым ещё на сервере, где никакого состояния панели не существовало; подмешать в него значение из браузера панель уже не может — она получила разметку, а не рецепт её постройки.
Если содержимому нужно реагировать на нажатия в рамке, оно интерактивное, и место ему в клиентском файле. Серверным остаётся то, что от нажатий не меняется, — и обычно это как раз самая тяжёлая часть экрана: текст, таблица, список из базы.
Ошибка про функцию приходит поздно: компилятор молчит — для него пропс-функция совершенно нормальна, — и в первый раз вы видите её на сборке или в консоли dev-сервера. Часть III дала инструменты, чтобы сдвинуть проверку в редактор: рекурсивный тип и условный тип.
Диагностика приходит одна: тип объекта «не может быть назначен для типа 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. Перепишите так, чтобы через границу не проезжала ни одна функция, а разметка на экране осталась прежней. Обработчик клика трогать не нужно — он и так живёт в клиентском файле. Последняя строка примера печатает разметку списка — после правки она должна выйти той же. Саму границу песочница не проверит: сервера в ней нет, оба «файла» выполняются в одной вкладке, так что эту половину сверьте с решением.
Форматирование — это вычисление, а вычисление можно сделать на сервере. Тогда через границу поедет строка, а Row перестанет знать про форматы вообще.
Пропс стал у́же, компонент — проще, а правила форматирования вместе с данными локали остались на сервере. Если бы форматов было несколько, через границу поехала бы не функция, а её имя: variant="money" — строка, и выбор по ней делает уже клиентский код.
Панель с дыркой
Клиентская панель импортирует содержимое сама — и утаскивает его в браузер вместе с собой. Переделайте её на слот: панель не должна знать, что внутри, а Stats должен остаться серверным. Разметка на экране не меняется.
Stats переезжает в свой файл — без директивы, то есть серверный, — а панель получает children и перестаёт что-либо знать о содержимом.
Импорт Stats исчез из клиентского файла и появился в серверном — этого и добивались. В браузер теперь уезжает только панель, а содержимое приезжает готовой разметкой, даже если завтра оно вырастет в таблицу на тысячу строк.
Строка из базы
Запрос вернул экземпляр класса — так делают многие ORM. Через границу он не проедет. Положите в forClient значение, которое проедет и сохранит id, title и createdAt; проверка в последней строке должна напечатать true.
Нужен литерал с перечисленными руками полями. Поля выбираются осознанно — это ровно тот момент, когда решается, что увидит посетитель.
createdAt оставлять датой можно: Date через границу проходит. А метод isFresh в браузере не воскреснет никогда — если его результат нужен, он считается на сервере и едет отдельным полем: fresh: row.isFresh(). Обратите внимание, чего в литерале нет: всего остального, что вернул запрос.