open atlas
↑ К треку
Паттерны React RXP · 10 · 01

Серверные против клиентских компонентов

В App Router серверные компоненты — это значение по умолчанию: ноль клиентского JS, прямой доступ к данным. Клиентские подключаются через 'use client' только ради интерактивности. По умолчанию — сервер, а границу опускай вниз к интерактивным листьям.

RXP Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

В треке React ты писал компоненты, и все они выполнялись в браузере. App Router незаметно меняет значение по умолчанию: файл компонента теперь — серверный компонент, пока ты не скажешь иначе. Он выполняется один раз на сервере, отправляет клиенту свой вывод — а не код, — и может await-ить базу данных прямо в теле функции. Никаких useState, никаких useEffect, никаких onClick — и ни одного байта в бандле.

Весь паттерн границы — это одно решение, повторяемое по всему дереву: нужно ли этому компоненту выполняться в браузере? Прими его верно — и клиентский бандл остаётся маленьким, а доступ к данным живёт на сервере. Прими неверно — один 'use client' не в том месте — и ты перетащишь через границу целое поддерево, заново собрав то самое полностью клиентское приложение, которое модель RSC и была призвана отправить на пенсию.

Цель

После этого урока ты можешь отличить серверный компонент от клиентского в App Router; точно сформулировать, что каждый может и не может (доступ к данным и ноль JS против состояния, эффектов, событий и браузерных API); разместить границу 'use client' на интерактивных листьях, а не в корне; и распознать режим отказа, при котором 'use client' на вершине дерева превращает всё приложение в клиентский бандл и выбрасывает выгоду RSC.

1

Серверные компоненты — это значение по умолчанию: они выполняются на сервере, отправляют ноль 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). Для него нет клиентского рантайма — он уже выполнился.

2

Клиентские компоненты подключаются через '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 или получать их пропсами от серверного компонента выше.

3

Граница направленна и заразна: 'use client' помечает точку входа, и всё, импортированное ниже, тоже становится клиентским кодом. 'use client' означает не «этот один компонент интерактивен» — а «клиентский бандл начинается здесь». Каждый модуль, который этот файл импортирует (и который сам не является границей серверного компонента), затягивается в браузерный бандл. Вот почему размещение — это вся игра: положение директивы решает, какая часть твоего дерева уедет в виде JavaScript.

// НЕВЕРНЫЙ инстинкт: пометить страницу, чтобы потомок мог использовать состояние
"use client";              // ⛔ теперь страница И все её потомки — клиентский код
import { Chart } from "./Chart";        // тяжёлый — теперь в бандле
import { Table } from "./Table";        // статичный — без нужды в бандле
export default function Dashboard() { /* ...одной кнопке понадобилось состояние... */ }

Одному компоненту понадобился обработчик клика — и вся панель (библиотека графиков, статичная таблица, всё целиком) стала клиентским бандлом и потеряла доступ к данным на сервере. Лекарство — никогда не двигать границу вверх; нужно опускать её вниз.

4

По умолчанию используй серверные компоненты и опускай '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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.