Серверные против клиентских компонентов
В App Router серверные компоненты — это значение по умолчанию: ноль клиентского JS, прямой доступ к данным. Клиентские подключаются через 'use client' только ради интерактивности. По умолчанию — сервер, а границу опускай вниз к интерактивным листьям.
В треке React ты писал компоненты, и все они выполнялись в браузере. App Router незаметно меняет значение по умолчанию: файл компонента теперь — серверный компонент, пока ты не скажешь иначе. Он выполняется один раз на сервере, отправляет клиенту свой вывод — а не код, — и может await-ить базу данных прямо в теле функции. Никаких useState, никаких useEffect, никаких onClick — и ни одного байта в бандле.
Весь паттерн границы — это одно решение, повторяемое по всему дереву: нужно ли этому компоненту выполняться в браузере? Прими его верно — и клиентский бандл остаётся маленьким, а доступ к данным живёт на сервере. Прими неверно — один 'use client' не в том месте — и ты перетащишь через границу целое поддерево, заново собрав то самое полностью клиентское приложение, которое модель RSC и была призвана отправить на пенсию.
После этого урока ты можешь отличить серверный компонент от клиентского в App Router; точно сформулировать, что каждый может и не может (доступ к данным и ноль JS против состояния, эффектов, событий и браузерных API); разместить границу 'use client' на интерактивных листьях, а не в корне; и распознать режим отказа, при котором 'use client' на вершине дерева превращает всё приложение в клиентский бандл и выбрасывает выгоду RSC.
Серверные компоненты — это значение по умолчанию: они выполняются на сервере, отправляют ноль JS и могут await-ить данные напрямую. Нет директивы, чтобы их включить; обычный компонент в App Router уже серверный. Он выполняется во время запроса, может быть async и может читать твой слой данных инлайн. Его отрендеренный вывод сериализуется на клиент; его исходный код — никогда.
// app/users/page.tsx — нет директивы = серверный компонент
import { db } from "~/lib/db";
export default async function UsersPage() {
const users = await db.user.findMany(); // выполняется на сервере, секреты остаются на сервере
return (
<ul>
{users.map((u) => <li key={u.id}>{u.name}</li>)}
</ul>
);
}Чего он не может: держать состояние, запускать эффекты, навешивать обработчики событий или трогать браузерные API (window, localStorage). Для него нет клиентского рантайма — он уже выполнился.
Клиентские компоненты подключаются через 'use client' — именно там живут состояние, эффекты, события и браузерные API. Директива в начале файла помечает его (и всё, что он импортирует и что само не является серверным компонентом) как код, отправляемый в браузер и гидратируемый там. Ты тянешься к ней только тогда, когда компоненту действительно нужна интерактивность.
// app/users/SearchBox.tsx
"use client";
import { useState } from "react";
export function SearchBox({ onChange }: { onChange: (q: string) => void }) {
const [q, setQ] = useState(""); // состояние — нужен клиент
return (
<input
value={q}
onChange={(e) => { setQ(e.target.value); onChange(e.target.value); }} // обработчик события — нужен клиент
/>
);
}Чего он не может: await-ить твою базу данных в теле или импортировать секреты только-для-сервера. Клиентский компонент выполняется в браузере, поэтому он должен загружать данные через API или получать их пропсами от серверного компонента выше.
Граница направленна и заразна: 'use client' помечает точку входа, и всё, импортированное ниже, тоже становится клиентским кодом. 'use client' означает не «этот один компонент интерактивен» — а «клиентский бандл начинается здесь». Каждый модуль, который этот файл импортирует (и который сам не является границей серверного компонента), затягивается в браузерный бандл. Вот почему размещение — это вся игра: положение директивы решает, какая часть твоего дерева уедет в виде JavaScript.
// НЕВЕРНЫЙ инстинкт: пометить страницу, чтобы потомок мог использовать состояние
"use client"; // ⛔ теперь страница И все её потомки — клиентский код
import { Chart } from "./Chart"; // тяжёлый — теперь в бандле
import { Table } from "./Table"; // статичный — без нужды в бандле
export default function Dashboard() { /* ...одной кнопке понадобилось состояние... */ }Одному компоненту понадобился обработчик клика — и вся панель (библиотека графиков, статичная таблица, всё целиком) стала клиентским бандлом и потеряла доступ к данным на сервере. Лекарство — никогда не двигать границу вверх; нужно опускать её вниз.
По умолчанию используй серверные компоненты и опускай 'use client' вниз к интерактивным листьям. Держи родителей на сервере, отдавай интерактивные кусочки маленьким клиентским листьям и передавай серверный контент в них как children или пропсы. Клиентский компонент может рендерить серверные компоненты, переданные как children, — они уже были отрендерены на сервере, — поэтому клиентская граница остаётся тонкой оболочкой вокруг действительно интерактивной части.
// Родитель — серверный компонент: данные + композиция остаются на сервере
export default async function Dashboard() {
const data = await getMetrics();
return (
<section>
<Chart data={data} /> {/* серверный компонент: ноль JS */}
<Collapsible> {/* крошечный клиентский лист для переключателя */}
<Table rows={data.rows} /> {/* серверный компонент, передан как children */}
</Collapsible>
</section>
);
}// Collapsible.tsx — ЕДИНСТВЕННЫЙ клиентский код здесь
"use client";
import { useState } from "react";
export function Collapsible({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen((o) => !o)}>{open ? "Hide" : "Show"}</button>
{open && children} {/* отрендеренная на сервере Table, без лишнего JS */}
</>
);
}Теперь JS отправляет только Collapsible. График, таблица и загрузка данных остаются на сервере. Граница — это лист, а не корень.
Страница товара, отправляющая слишком много JS, чинится переносом одной директивы. Странице нужны серверные данные (товар) и один интерактивный кусочек (кнопка «в корзину» со счётчиком количества). Первая попытка помечает саму страницу как клиентский компонент, чтобы счётчик мог использовать состояние:
// ДО — app/product/[id]/page.tsx
"use client"; // ⛔ граница в корне
import { useState } from "react";
import { ProductGallery } from "./ProductGallery"; // тяжёлая, статичная
import { Reviews } from "./Reviews"; // статичные, серверные данные
export default function ProductPage({ params }: { params: { id: string } }) {
const [qty, setQty] = useState(1);
// здесь нельзя await-ить БД — теперь это клиентский компонент, поэтому грузим через API:
const product = useProduct(params.id); // лишний раунд-трип, состояние загрузки, больше JS
if (!product) return <Spinner />;
return (
<main>
<ProductGallery images={product.images} /> {/* отправлена как JS */}
<h1>{product.name}</h1>
<input type="number" value={qty} onChange={(e) => setQty(+e.target.value)} />
<button onClick={() => addToCart(product.id, qty)}>Add</button>
<Reviews productId={product.id} /> {/* отправлены как JS */}
</main>
);
}Всё — галерея, отзывы, вся страница — теперь клиентский бандл, а товар загружается на клиенте вместо await на сервере. Лекарство: оставить страницу на сервере и вынести в клиентский лист только счётчик.
// ПОСЛЕ — app/product/[id]/page.tsx (снова серверный компонент)
import { db } from "~/lib/db";
import { ProductGallery } from "./ProductGallery";
import { Reviews } from "./Reviews";
import { AddToCart } from "./AddToCart"; // единственный клиентский компонент
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await db.product.find(params.id); // напрямую, на сервере, без захода в API
return (
<main>
<ProductGallery images={product.images} /> {/* Server, ноль JS */}
<h1>{product.name}</h1>
<AddToCart productId={product.id} /> {/* клиентский лист */}
<Reviews productId={product.id} /> {/* Server, ноль JS */}
</main>
);
}// ПОСЛЕ — app/product/[id]/AddToCart.tsx
"use client";
import { useState } from "react";
export function AddToCart({ productId }: { productId: string }) {
const [qty, setQty] = useState(1);
return (
<>
<input type="number" value={qty} onChange={(e) => setQty(+e.target.value)} />
<button onClick={() => addToCart(productId, qty)}>Add</button>
</>
);
}Тот же UI. Но теперь галерея и отзывы отправляют ноль JS, товар await-ится на сервере (без клиентской загрузки, без спиннера, без водопада), а клиентский бандл — это один маленький счётчик вместо всей страницы.
▸Почему это работает
Почему «сервер по умолчанию» — это правильное значение по умолчанию, а не нейтральное? Потому что по умолчанию — это бо́льшая часть реального приложения: вёрстка, текст, отображение данных, навигация — ничто из этого не интерактивно. Сделав их бесплатными (ноль JS, доступ к данным на сервере) по умолчанию и взимая плату только за интерактивные части, ты получаешь клиентский бандл, который масштабируется с интерактивностью, а не с размером страницы. Приложение, насыщенное текстом для чтения, может отправлять почти ноль JavaScript. Переверни значение по умолчанию — клиент по умолчанию, сервер как опция — и ты будешь платить за частый случай, чтобы сделать удобным редкий, что ровно наоборот.
▸Частая ошибка
Характерный режим отказа — поставить 'use client' ближе к вершине дерева, «чтобы не думать об этом», — часто на layout или на странице, — чтобы удовлетворить одного интерактивного потомка. Это глушит ошибки границы, и приложение работает, поэтому оно проходит ревью. Но оно тихо превратило всё поддерево в клиентский бандл и убило доступ к данным на сервере для всего, что ниже. Признак: ошибка серверного компонента вроде «ты импортируешь компонент, которому нужен useState… пометь его ‘use client’», на которую отвечают добавлением директивы в импортирующий (родительский) файл вместо маленького листа, которому на самом деле нужно состояние. Правило большого пальца: уперевшись в границу, опускай директиву вниз к самому маленькому интерактивному компоненту, никогда не вверх.
Страница — серверный компонент, который await-ит БД. Одному потомку нужен переключатель на useState. Сборка падает: 'useState работает только в клиентском компоненте.' Какое senior-решение?
В App Router серверные компоненты — это значение по умолчанию: они выполняются на сервере, отправляют ноль JS и могут await-ить данные напрямую — но не могут держать состояние, запускать эффекты, навешивать обработчики событий или использовать браузерные API. Клиентские компоненты подключаются через 'use client' ровно ради этих интерактивных нужд, а взамен не могут напрямую обращаться к серверным данным или секретам. Директива — это точка входа, а не ярлык: всё, импортированное ниже неё, становится клиентским кодом, поэтому её положение решает, какая часть твоего дерева уедет в виде JavaScript. Senior-паттерн — по умолчанию серверные компоненты и опускание 'use client' вниз к интерактивным листьям, с передачей серверного контента в эти листья как children. Режим отказа, который нужно распознавать и отклонять на ревью, — это 'use client' на вершине дерева (на странице или layout), который удовлетворяет одного интерактивного потомка ценой превращения всего поддерева в клиентский бандл и выбрасывания выгоды RSC. Уперевшись в границу, двигай директиву вниз, никогда не вверх.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.