Граница 'use client' и почему она заразна
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Серверный компонент отдаёт разметку и исчезает: ни состояния, ни обработчиков у него нет. Как только они нужны, код должен уехать в браузер — и место, где он туда уезжает, вы отмечаете сами, строкой "use client" в начале файла. Директива помечает не компонент, а вход в клиентскую часть: всё, что этот файл импортирует, тоже становится клиентским.
Директива — это обычная строка-литерал, стоящая в файле раньше всего остального. Сборщик Next читает её до того, как начнёт разбирать импорты, и помечает модуль как вход в клиентскую часть приложения: отсюда начинается код, который поедет в браузер.
Требование «раньше всего остального» — не придирка к стилю. Строка считается директивой только в прологе файла: выше неё можно поставить комментарии и пустые строки, но ни одной инструкции. Один import сверху — и это уже не директива, а бесполезное выражение посреди кода.
Ошибка от этого получается обидная: сборка падает не на самой строке, а на первом хуке — Next сообщает, что useState работает только в клиентском компоненте, и советует пометить файл директивой. Директива стоит, вы её видите и не понимаете, чего от вас хотят. Смотреть нужно не на наличие строки, а на то, что стоит выше неё.
Директива помечает файл целиком, а не отдельный компонент. Если из пяти компонентов в файле браузер нужен одному — это повод разнести их по файлам, а не помечать все пять.
Сборщику нужно знать сторону модуля до того, как он его разберёт, — иначе он не поймёт, куда складывать результат. Строка-литерал видна в первых байтах файла, не требует ни импорта, ни рантайма и переживает любую трансляцию: ровно так же работает "use strict", знакомый вам с части I.
Второе соображение — переносимость. Директива живёт в самом файле, а не в конфиге сборщика, поэтому модуль можно перенести в другой проект вместе с его стороной. У границы есть и зеркальная директива — "use server", но она про другое: про функции, которые можно вызвать с клиента. О ней — в главе про изменение данных.
Серверный компонент выполняется один раз и превращается в описание разметки. Продолжения во времени у него нет: некому запомнить, что кнопку нажали, и некому эту кнопку слушать. В части II вы собирали такой цикл руками — состояние, перерисовка, делегирование событий, — и весь тот код по необходимости жил в браузере. Ничего не изменилось: он и сейчас должен быть там.
Директива в примере стоит для правдоподобия: песочница про неё ничего не знает, для неё это просто строка. Разбирает директивы сборщик Next, а не браузер и не компилятор TypeScript.
Список того, что тянет за собой границу, короткий и запоминается целиком.
| Что в файле | Нужна ли директива |
|---|---|
useState, useReducer | да — это память между рендерами |
useEffect, useLayoutEffect | да — эффекты бывают только в браузере |
onClick и другие обработчики в JSX | да — обработчик надо кому-то вызвать |
createContext, useContext | да — и провайдер, и потребитель |
useRef на узел, ref как пропс | да — узла на сервере не существует |
window, document, localStorage | да |
| классовый компонент, граница ошибок | да — классы серверными не бывают |
| библиотека, внутри которой всё это есть | да — считается по её коду, а не по вашему |
await за данными, чтение переменных окружения | нет — и лучше оставить на сервере |
| разметка, условия, списки, вычисления по пропсам | нет |
Обратная половина таблицы важнее первой. Компонент, который берёт пропсы и возвращает разметку, прекрасно работает на сервере — даже если внутри у него условия, циклы и форматирование. Такие компоненты в приложении составляют большинство, и держать их серверными ничего не стоит.
await границы не требует: серверный компонент умеет дожидаться данных сам, без useEffect. Об этом — отдельная статья главы.
Директива стоит в одном файле, но помечает не его одного. С этого файла сборщик идёт по импортам вглубь, и всё, до чего дойдёт, компилирует для браузера. Посчитаем клиентскую часть сами — обходом графа, тем же способом, каким это делает сборщик.
Директива одна, файлов в браузере четыре. И два из них попали туда не потому, что кто-то так решил, а по цепочке: lib/track.ts импортирован кнопкой, а lib/queue.ts — самим track, о котором при расстановке директивы никто и не думал.
| Файл | Как оказался в браузере |
|---|---|
app/post/like-button.tsx | в нём стоит директива — это вход |
lib/format.ts | импортирован кнопкой; заодно остаётся и на сервере — его импортирует статья |
lib/track.ts | импортирован кнопкой |
lib/queue.ts | импортирован track — приехал за компанию |
app/post/article.tsx | не приехал: его никто из клиентской части не импортирует |
app/post/page.tsx | не приехал: он импортирует кнопку, а не наоборот |
Отсюда два следствия. Первое: файлам ниже границы своя директива не нужна — они уже клиентские. Написать её там не ошибка, но это шум: читатель кода начнёт искать, зачем понадобился ещё один вход. Второе: lib/format.ts живёт в обоих мирах и компилируется дважды — это нормально ровно до тех пор, пока в нём нет ничего серверного. Кто в Next на самом деле режет граф и как один файл попадает сразу в несколько слоёв сборки, видно по файлам настоящей сборки.
Заразность идёт вниз по импортам, а не вверх по разметке. Родитель клиентского компонента остаётся серверным. И то, что клиентский компонент получил пропсом, тоже может остаться серверным — об этом следующая статья.
Файл components/index.ts, реэкспортирующий сорок компонентов, кажется удобством: import { Button } from "@/components" вместо длинного пути. Такой файл называют бочкой (barrel), и импорт из него — это импорт всего модуля: сборщику приходится разобрать все сорок файлов, чтобы понять, что из них можно выбросить. Неиспользованное он обычно выбрасывает, но не всё: модуль с побочным эффектом на верхнем уровне выбросить нельзя, и он уезжает в браузер целиком.
Лечится это тем же, чем и всегда: импортом из конкретного файла. Из клиентского компонента — тем более, там цена ошибки не время сборки, а мегабайты у читателя.
Раз директива заразна, место для неё выбирают не там, где удобно писать, а там, где дешевле платить. Проверить это можно, не выходя из браузера: React ведёт себя точно так же, только вместо «уехало в бандл» у него «перерисовалось». Одна и та же страница, две границы — сравните вывод.
После клика по левой кнопке Article рисуется заново, после клика по правой — нет. В браузере цена этому — лишний вызов функции; на сервере она другая и куда крупнее: в варианте A статья вместе со всеми своими импортами уехала бы в браузер, а в варианте B она остаётся разметкой, которую браузер получает готовой. Правило одно на оба случая: держите состояние — и границу — как можно ниже.
На практике это значит: из большого компонента вырезается маленький интерактивный кусок, и директива достаётся только ему.
В песочнице оба компонента лежат в одном файле, потому что она выполняет ровно один, и монтирование написано руками. В проекте это два файла, страница уходит через export default, а директива стоит только в файле кнопки.
Граница проходит по сборке, а типы через неё видны как обычно: пропсы клиентского компонента проверяются там же, где и всегда. Компилятор про границу ничего не знает — и это к лучшему, потому что ошибку он ловит одинаково по обе стороны.
Диагностика есть, а кнопка всё равно нарисовалась и посчитала: строка "0" доехала до useState, и первое же нажатие превратило её в "01" — сложение строки с числом из части I. Типы живут до сборки, в браузер уезжает обычный JavaScript, поэтому диагностику компилятора нельзя пролистывать: за границей она единственная, кто предупредит.
Обратный ход — поставить директиву в раскладку — цепляет всё, что раскладка импортирует: шапку, меню, логотип и их зависимости. Сама страница клиентской при этом не станет, она приезжает в раскладку через children уже готовой, но шапка уедет в браузер целиком — из-за одной кнопки внутри неё. Дешевле вынести кнопку в свой файл: директива достанется ей одной.
Так действительно можно: приложение, где каждый файл клиентский, — это обычное React-приложение, каким вы его писали до этой главы. Работать оно будет. Платить придётся тем же, чем платил бы React без сервера: весь код компонентов едет к читателю, данные ходят отдельным запросом, а ключи и запросы к базе в такой файл положить уже нельзя.
Разумная стратегия обратная: по умолчанию всё серверное, директива добавляется в тот момент, когда сборка попросит. Это редкий случай, когда ленивый путь совпадает с правильным.
Слово «клиентский» подсказывает больше, чем есть на самом деле, и из этого растут четыре устойчивых заблуждения.
| Кажется | На самом деле |
|---|---|
| клиентский компонент выполняется только в браузере | при заходе на страницу его первый рендер происходит на сервере: в HTML приезжает готовая разметка, а в браузере компонент оживает |
| директива нужна каждому клиентскому файлу | только точке входа; ниже по импортам она уже ничего не меняет |
| клиентский компонент грузится отдельно и лениво | нет, он часть обычного бандла; ленивость — это отдельное решение |
| клиентский компонент не может показать серверный | может, если получил его пропсом или через children |
Первая строка таблицы — самая дорогая на практике. Тело клиентского компонента выполняется дважды, и первый раз это происходит там, где нет ни window, ни localStorage. Поэтому обращения к браузеру живут в эффекте или в обработчике, а не в теле компонента.
Песочница — это браузер, здесь window есть всегда, и падения на первом рендере вам не показать. Порядок строк в выводе, впрочем, настоящий: тело компонента раньше эффекта.
Разница между «серверным компонентом» и «серверным рендером клиентского» стоит того, чтобы её развести окончательно, — этому посвящена статья «Серверный компонент, SSR и гидратация — три разные вещи». Пока достаточно правила: директива не отменяет серверный рендер, она добавляет к нему браузерную жизнь.
- "use client" — строка в прологе файла: выше неё допустимы только комментарии, любая инструкция её отменяет.
- Границу требует всё, что живёт во времени: состояние, обработчики, эффекты, контекст, обращения к window.
- Директива помечает не компонент, а вход: импорты этого файла и их импорты тоже уезжают в браузер.
- Заразность идёт вниз по импортам, а не вверх по разметке — родитель клиентского компонента остаётся серверным.
- Границу держат в листьях: чем ниже директива, тем меньше кода и данных достаётся браузеру.
- «Клиентский» не значит «только в браузере»: первый рендер такого компонента всё равно происходит на сервере.
1/4В файле сверху стоит комментарий с описанием компонента, затем import из "react", затем строка "use client", затем компонент с useState. Что сделает сборка?
2/4Клиентский компонент импортирует lib/format.ts, а тот — lib/plural.ts. Директива стоит только в компоненте. Что попадёт в браузер?
3/4Серверная страница импортирует клиентскую кнопку и рисует её внутри себя. Что происходит со страницей?
4/4Компонент помечен "use client" и в теле, до всякого эффекта, читает localStorage. Что будет при первом заходе на страницу?
Опустить границу
Вся страница помечена директивой из-за одной кнопки: в браузер уезжает и текст статьи, и заголовок. Разделите компонент на два так, чтобы клиентским остался только тот, которому это нужно, и объясните, в каком из файлов окажется директива.
Интерактив здесь один — счётчик нажатий. Он и уезжает в свой файл вместе с директивой, а страница остаётся серверной: она только рисует разметку и вставляет кнопку.
Страница импортирует кнопку — и от этого клиентской не становится: заразность идёт вниз по импортам. В браузер уезжает файл кнопки и то, что импортирует он сам.
Секрет уехал в браузер
Граница объявлена в app/buy-button.tsx, и в клиентской части оказался lib/db.ts — файл, который открывает соединение с базой и читает ключ из окружения. Найдите цепочку, по которой он туда попал, и поправьте imports так, чтобы его в списке не осталось. Затем скажите словами, как то же самое делается в настоящем коде.
Цепочка видна из графа: buy-button → lib/cart.ts → lib/db.ts. Кнопка импортирует корзину, корзина — базу, и вся тройка уезжает в браузер. Рвать цепочку нужно на первом звене: убрать lib/cart.ts из импортов кнопки.
Теперь в списке останутся только app/buy-button.tsx и lib/price.ts.
В настоящем коде это делается так же прямолинейно: работу с базой выполняет серверная часть, а кнопке достаётся результат — пропсом или вызовом действия. Импортировать серверный модуль из клиентского файла нельзя не потому, что «не принято», а потому что вместе с ним в браузер уедет всё, что в нём написано, включая ключи.
Подстраховаться можно пакетом server-only: импорт этого пакета в модуле ломает сборку, если модуль оказался в клиентской части. Тогда ошибка приходит на сборке, а не после выкладки.
Директива не сработала
В песочнице поле работает: она выполняет обычный браузерный код и про директивы ничего не знает. А вот сборка Next на этом файле упадёт — скажет, что useState работает только в клиентском компоненте, и предложит пометить файл директивой. Директива стоит. Объясните, почему Next её не видит, и почините файл.
Строка стоит ниже объявления константы, а пролог файла кончается на первой же инструкции. Для сборщика это не директива, а выражение-строка посреди модуля: вычислили, выбросили. Файл считается серверным, отсюда и жалоба на хук.
Комментарий выше директивы допустим — прологу он не мешает, комментарии не инструкции. Всё остальное — нет: константа, импорт, export. Чаще всего виноват именно импорт, добавленный редактором автоматически: он встаёт первой строкой и молча отменяет директиву.