Правильное нарисование границы «use client» в Next.js App Router
Узнайте, что на самом деле обозначает директива ‘use client’, почему маркировка контейнеров увеличивает размер пакетов, и как листовые компоненты и слоты дочерних элементов сохраняют обработку на сервере.
Если открыть множество проектов с App Router, вы обнаружите, что в верхней части почти каждого компонента присутствует строка 'use client'. Эта привычка формируется быстро: вы добавляете обработчик onClick или функцию useState для выпадающего списка, сборка выдает ошибку, но использование данной директивы устраняет её. Повторив это несколько десятков раз, в итоге в бандл браузера попадает вся структура приложения, что превращает его в одностраничное приложение с излишними сложностями. В этом руководстве объясняется, что на самом деле делает эта директива и как её использовать, чтобы серверные операции оставались на сервере, а только по-настоящему интерактивные элементы отправлялись в виде JavaScript.
Что на самом деле обозначает директива
Название наводит на мысль о том, что «это следует запускать только в браузере», но это не его смысл. 'use client' указывает на границу модуля: место, где заканчивается структура серверных модулей и начинается пакет клиентского кода. Клиентские компоненты всё равно сначала предварительно преобразуются в HTML на сервере; затем они дополнительно активируются в браузере.
[ Server Component Tree ] (Executes solely on the server; zero KB client JS)
│
├── Server Component A (Fetches directly from DB)
│
▼
── [ 'use client' Boundary ] ──
│
├── Client Component B (Pre-rendered to HTML on server, hydrated on client)
│ │
│ └── Regular Component C (Now forced into the client bundle!)
Последствия, сбивающие команды с толку, носят транзитивный характер. Каждый модуль, импортируемый файлом с маркировкой 'use client', также становится частью клиентского пакета, независимо от того, содержит ли сам файл эту директиву. Если лayout основной панели управления помечен как клиентский модуль из-за наличия выпадающего списка с профилями, то его боковая панель, модальные окна, вспомогательные элементы и все тяжелые библиотеки, которые он импортирует, также передаются в браузер. Чтобы узнать подробнее, почему код, работающий исключительно на сервере, не увеличивает размер пакета, прочитайте как React Server Components обеспечивают отсутствие увеличения размера пакета при рендеринге.
Ловушка интерактивного контейнера
Самая распространённая ошибка — преобразование всей страницы в клиентский компонент только потому, что небольшая его часть интерактивна. В приведённом ниже примере состояние нужно лишь для переключения фильтра, однако вся панель управления отмечается как клиентский код:
// ❌ BAD: The entire page is pulled into the client bundle
'use client';
import { useState, useEffect } from 'react';
import { db } from '@/lib/db'; // 🚨 Build error or massive security/bundle hazard!
export default function UserDashboard() {
const [isOpen, setIsOpen] = useState(false);
const [data, setData] = useState(null);
useEffect(() => {
// You've recreated client-side waterfall fetching
fetch('/api/user-data').then(res => res.json()).then(setData);
}, []);
return (
<div className="p-8">
<button onClick={() => setIsOpen(!isOpen)}>Toggle Filter</button>
{isOpen && <div className="dropdown">...</div>}
<div className="grid">
{/* Render heavy data tables */}
</div>
</div>
);
}
Это приводит к двум проблемам. Импорт клиента базы данных в клиентский модуль либо приводит к сбою сборки, либо, что ещё хуже, создаёт риск переноса кода и конфигурации, предназначенных только для сервера, в браузер. Кроме того, загрузка данных происходит внутри функции useEffect, поэтому страница отображается пустой, а затем после гидратации запрашивает данные, вновь создавая ту же цепочку операций на стороне клиента, которую должны были устранить серверные компоненты. Добавление пакета server-only в такие модули, как @/lib/db, превращает первую ошибку в явную ошибку сборки.
Переносите интерактивность на самые низкие уровни
Статус должен находиться в самом маленьком компоненте, который его требует. Здесь переключатель становится отдельным клиентским модулем, а все, что он отображает, передается в качестве children:
// components/FilterToggle.tsx
'use client';
import { useState } from 'react';
export function FilterToggle({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button
onClick={() => setIsOpen(!isOpen)}
className="px-3 py-1.5 bg-neutral-100 rounded text-sm"
>
{isOpen ? 'Hide Filters' : 'Show Filters'}
</button>
{isOpen && <div className="mt-2">{children}</div>}
</div>
);
}
Страница остается асинхронным серверным компонентом. Он напрямую запрашивает базу данных, отображает статический маркап и встраивает переключатель только там, где происходит взаимодействие:
// app/dashboard/page.tsx (Server Component by default)
import { db } from '@/lib/db';
import { FilterToggle } from '@/components/FilterToggle';
import { AnalyticsChart } from '@/components/AnalyticsChart';
export default async function UserDashboard() {
// Direct DB access — zero client-side fetch waterfalls
const metrics = await db.metrics.findMany();
return (
<main className="p-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-xl font-bold">Performance Analytics</h1>
<FilterToggle>
<p className="text-sm text-neutral-500">Filter options...</p>
</FilterToggle>
</div>
<div className="grid grid-cols-3 gap-4">
{/* AnalyticsChart can also remain an RSC if it doesn't need canvas/DOM APIs */}
<AnalyticsChart data={metrics} />
</div>
</main>
);
}
Параграф, переданный в FilterToggle, отображается на сервере, хотя и находится внутри клиентского компонента. AnalyticsChart может оставаться серверным компонентом, если он не использует API, доступные только в браузере, такие как canvas; если же использует, то только этот график требует соответствующей директивы.
Составление серверного контента внутри клиентских оболочек
Та же техника применима и к макетам. Предположим, у нас есть клиентский компонент CollapsibleShell, который управляет состоянием открытия и закрытия, подобно FilterToggle. Макет, являющийся серверным компонентом, генерирует ресурсоемкий поток данных и передает его шеллу в виде слота:
// app/layout.tsx
import { CollapsibleShell } from '@/components/CollapsibleShell';
import { ExpensiveServerFeed } from '@/components/ExpensiveServerFeed';
export default function RootLayout() {
return (
<html lang="en">
<body>
<CollapsibleShell>
{/* ExpensiveServerFeed runs on the server, streams HTML, and ships 0kb JS */}
<ExpensiveServerFeed />
</CollapsibleShell>
</body>
</html>
);
}
Поскольку ExpensiveServerFeed создается в серверной среде и передается через параметр children, его отрисовка происходит полностью на сервере. React отправляет полученный результат через границу в загрузочном пакете RSC, и код компонента никогда не попадает в клиентский бандл. Ключевое отличие заключается в том, что клиентский модуль, импортирующий компонент, включает его в бандл, тогда как клиентский компонент, получающий уже отрисованные элементы в качестве свойств, этого не делает.
Чек-лист перед добавлением директивы
- Использует ли этот компонент состояние, эффекты, обработчики событий или API браузера? Если нет, оставьте его на сервере.
- Можно ли выделить интерактивную часть в отдельный более мелкий дочерний компонент?
- Можно ли передавать содержимое, отрендеренное на сервере, через параметр
childrenили другой атрибут вместо импорта? - Являются ли все параметры, пересекающие границу, сериализуемыми, без функций или экземпляров классов, за исключением Server Actions?
- Приведет ли пометка этого файла к подключению тяжелой библиотеки, такой как парсер Markdown или инструменты для работы с датами, которые можно было бы оставить на сервере?
Основные выводы
- Компоненты сервера являются стандартом не просто так; храните там загрузку данных, инструменты парсинга и статический маркап.
- Изолируйте небольшие интерактивные элементы, вместо того чтобы преобразовывать их контейнеры.
children и другие атрибуты слотов для обертки серверного вывода в клиентское поведение без его отправки.'use client' как осознанную архитектурную границу, а не способ скрыть ошибку сборки. При правильном применении бандлы остаются небольшими, а контент сразу отображается после загрузки.