Модуль 2
React Server Components (RSC)
- Серверные vs клиентские компоненты
- "use client"
- Композиция
- Suspense и стриминг
#2.1. Серверные vs клиентские компоненты
В Next.js App Router все компоненты по умолчанию серверные (Server Components). Это фундаментальное отличие от классического React, где все компоненты — клиентские. Эта концепция стабильна в Next.js 16 + React 19.2 — изложенный ниже материал останется актуальным в ближайшие годы.
#Простая проверка: серверный или клиентский?
Перед переходом к деталям запомните три вопроса. Ответ «Да» хотя бы на один из них означает: компонент должен быть клиентским (с «use client»).
- Нужен ли useState, useEffect, useRef, useContext или другой хук?
- Нужны ли обработчики событий — onClick, onChange, onSubmit?
- Нужны ли браузерные API — window, document, localStorage, navigator?
Если на все три вопроса ответ «Нет» — оставляйте компонент серверным. По умолчанию все компоненты в app/ — серверные, ничего специально писать не нужно.
#Что такое Server Component?
Server Component — это компонент, который рендерится только на сервере. Его код никогда не попадает в JavaScript-бандл браузера.
Преимущества серверных компонентов:
- Прямой доступ к БД, файловой системе, env-переменным
- Не увеличивают размер JS-бандла
- Кэширование данных на сервере (в Next.js 15/16 его включают явно, см. модуль 5)
- Безопасность — секреты остаются на сервере
Ограничения серверных компонентов:
- Нельзя использовать хуки (useState, useEffect и т.д.)
- Нельзя использовать браузерные API (window, document)
- Нельзя добавлять обработчики событий (onClick, onChange)
- Нельзя использовать Context API
#Что такое Client Component?
Client Component — это обычный React-компонент, который рендерится на клиенте (и предварительно на сервере для SSR). Для создания клиентского компонента нужно добавить директиву "use client" в начало файла.
Когда нужен Client Component:
- Интерактивность — кнопки, формы, модальные окна
- Хуки — useState, useEffect, useRef и т.д.
- Браузерные API — localStorage, geolocation
- Сторонние библиотеки, которые используют хуки
#Сравнительная таблица
| Server Component | Client Component | |
|---|---|---|
| Рендеринг | Только сервер | Сервер (SSR) + клиент |
| JS-бандл | Не попадает | Попадает |
| Хуки | ❌ | ✅ |
| onClick и др. | ❌ | ✅ |
| async/await | ✅ (в компоненте) | ❌ (только в useEffect) |
| Доступ к БД | ✅ напрямую | ❌ (через API/Action) |
| Директива | По умолчанию | "use client" |
#2.2. Практика: серверный и клиентский компоненты
Шаг 1. Создайте серверный компонент app/server-example/page.tsx:
// app/server-example/page.tsx
// Это Server Component (по умолчанию, без "use client")
import { readFile } from "fs/promises";
import path from "path";
export default async function ServerPage() {
// Можем читать файлы на сервере!
const packageJson = await readFile(
path.join(process.cwd(), "package.json"),
"utf-8"
);
const pkg = JSON.parse(packageJson);
// Можем использовать env-переменные
const nodeEnv = process.env.NODE_ENV;
console.log("Этот console.log виден ТОЛЬКО в терминале сервера!");
return (
<div>
<h1>Server Component</h1>
<p>Название проекта: {pkg.name}</p>
<p>Версия Next.js: {pkg.dependencies.next}</p>
<p>Окружение: {nodeEnv}</p>
<p>
Наш console.log напечатался в терминале сервера.
В браузере он тоже мелькнёт, но с плашкой Server — это пересылка логов в dev.
</p>
</div>
);
}Шаг 2. Проверьте:
- Откройте http://localhost:3000/server-example
- Данные из package.json отображаются на странице
- Посмотрите в терминал — console.log есть, и это главное: код выполнился на сервере
- Откройте DevTools → Console — в режиме npm run dev сообщение видно и здесь, но с серой плашкой Server: так Next.js пересылает серверные логи в браузер для удобства отладки (разбор — в заметке ниже)
Шаг 3. Создайте клиентский компонент app/components/Counter.tsx:
// app/components/Counter.tsx
"use client"; // ← Это делает компонент клиентским!
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
console.log("Этот console.log виден В БРАУЗЕРЕ!");
return (
<div style={{
padding: "1rem",
border: "1px solid blue",
borderRadius: "8px",
}}>
<h3>Счётчик (Client Component)</h3>
<p>Значение: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
<button
onClick={() => setCount(count - 1)}
style={{ marginLeft: "0.5rem" }}
>
-1
</button>
</div>
);
}Шаг 4. Создайте app/client-example/page.tsx:
// app/client-example/page.tsx
// Это Server Component!
import { Counter } from "../components/Counter";
export default function ClientExamplePage() {
return (
<div>
<h1>Пример клиентского компонента</h1>
<p>Текст ниже рендерится на сервере.</p>
<Counter />
<p>А этот текст тоже на сервере.</p>
</div>
);
}Шаг 5. Проверьте:
- Откройте http://localhost:3000/client-example
- Счётчик работает — кнопки +1 и -1 кликабельны
- Откройте DevTools → Console — console.log из Counter есть
- Страница page.tsx — серверная, Counter — клиентский
#2.3. Директива "use client" — правила и нюансы
Правило 1: "use client" ставится в начале файла
"use client";
// Все экспорты из этого файла — клиентские компоненты
import { useState } from "react";
export function MyComponent() { /* ... */ }
export function AnotherComponent() { /* ... */ } // тоже клиентский!Правило 2: "use client" создаёт границу (boundary)
При добавлении "use client" все импортируемые в этот файл модули тоже становятся частью клиентского бандла:
"use client";
// utils.ts тоже попадёт в клиентский бандл!
import { formatDate } from "./utils";
// Но Server Component нельзя импортировать в Client Component
// import { ServerOnly } from "./ServerOnly"; // ❌ Ошибка!Правило 3: Минимизируйте "use client"
Не делайте целую страницу клиентской. Выносите интерактивные части в отдельные маленькие компоненты:
❌ Плохо — вся страница клиентская:
// app/products/page.tsx
"use client"; // ← Вся страница в бандле!
import { useState } from "react";
export default function ProductsPage() {
const [filter, setFilter] = useState("");
// ... много статического контента
// ... который мог бы быть серверным
}// app/components/ProductFilter.tsx
"use client";
import { useState } from "react";
export function ProductFilter() {
const [filter, setFilter] = useState("");
return (
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Фильтр..."
/>
);
}
// app/products/page.tsx (Server Component!)
import { ProductFilter } from "../components/ProductFilter";
export default async function ProductsPage() {
const products = await db.product.findMany(); // Прямой доступ к БД
return (
<div>
<h1>Товары ({products.length})</h1>
<ProductFilter />
<ul>
{products.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</div>
);
}#"use client" vs "use server" — не путайте!
Это две разные директивы, и их регулярно путают. Запомните:
- «use client» — в начале файла. Делает компоненты в этом файле клиентскими (могут использовать useState, onClick и т.д.).
- «use server» — в начале файла или внутри функции. Делает функции Server Actions — серверными функциями, которые можно вызывать из клиента (изучим в модуле 6).
Не путайте: «use server» не делает компонент серверным. Серверный компонент — это компонент без «use client». Никакой директивы для этого писать не нужно.
#Типовые ошибки и что они значат
- useState (или другой React-хук) используется в серверном компоненте. Добавьте «use client» в начало файла — или вынесите интерактивную часть в отдельный клиентский компонент (это правильнее).
- Обратная ситуация: серверный API используется в клиентском компоненте. Уберите «use client» — или вынесите серверную часть в отдельный файл без «use client».
- Обычная функция (например, обработчик) передана из серверного компонента в клиентский через props. Так делать нельзя: функции не сериализуются. Решения: (1) сделать функцию Server Action через «use server», (2) объявить функцию внутри клиентского компонента, (3) передать примитив (строку, число) вместо функции.
- Серверный компонент пытается обратиться к браузерному API. Решение — вынести этот код в клиентский компонент (с «use client»). На сервере window и document не существуют.
#Подсказка: новый хук use() из React 19
React 19 ввёл хук use() — он позволяет «развернуть» промис в JSX. В серверных компонентах для этого традиционно используется await, но use() удобен в клиентских компонентах, которые получают промис через props:
// Клиентский компонент
"use client";
import { use } from "react";
export function PostBody({ postPromise }: { postPromise: Promise<Post> }) {
const post = use(postPromise); // развернули промис
return <article>{post.content}</article>;
}На практике use() встретится в модуле 4: там он распаковывает params в клиентском компоненте.
#2.4. Паттерны композиции
#Паттерн 1: Серверный родитель → Клиентский потомок
Самый частый паттерн. Серверный компонент получает данные и передаёт их клиентскому через props:
// app/components/ProductCard.tsx
"use client";
import { useState } from "react";
type Props = {
name: string;
price: number;
};
export function ProductCard({ name, price }: Props) {
const [inCart, setInCart] = useState(false);
return (
<div style={{
border: "1px solid #ddd",
padding: "1rem",
borderRadius: "8px",
}}>
<h3>{name}</h3>
<p>{price} ₽</p>
<button onClick={() => setInCart(!inCart)}>
{inCart ? "✅ В корзине" : "Добавить в корзину"}
</button>
</div>
);
}
// app/products/page.tsx (Server Component)
import { ProductCard } from "../components/ProductCard";
export default async function ProductsPage() {
const products = [
{ id: 1, name: "Ноутбук", price: 89990 },
{ id: 2, name: "Мышь", price: 2990 },
{ id: 3, name: "Клавиатура", price: 5990 },
];
return (
<div>
<h1>Товары</h1>
<div style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: "1rem",
}}>
{products.map((product) => (
<ProductCard
key={product.id}
name={product.name}
price={product.price}
/>
))}
</div>
</div>
);
}#Паттерн 2: Children — серверный контент внутри клиентского
Клиентский компонент может принимать серверные компоненты через children:
// app/components/Accordion.tsx
"use client";
import { useState } from "react";
export function Accordion({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>
{title} {isOpen ? "▲" : "▼"}
</button>
{isOpen && <div>{children}</div>}
</div>
);
}
// app/faq/page.tsx (Server Component)
import { Accordion } from "../components/Accordion";
// Этот компонент — серверный!
async function ServerContent() {
// Имитация запроса к серверу (как в разделе 2.6, через setTimeout)
await new Promise((r) => setTimeout(r, 500));
const answer = "Курс подходит новичкам с базовым знанием JavaScript.";
return <p>Ответ с сервера: {answer}</p>;
}
export default function FAQPage() {
return (
<div>
<h1>FAQ</h1>
<Accordion title="Вопрос 1">
{/* Серверный контент внутри клиентского компонента! */}
<ServerContent />
</Accordion>
</div>
);
}#Когда нужно вызвать серверный код из клиента
Иногда клиентскому компоненту нужно вызвать серверный код — например, сохранить данные в БД по клику на кнопку. Но клиентский компонент не может импортировать серверные функции напрямую.
Для этого есть Server Actions — отдельная концепция, которой посвящён модуль 6. Сейчас достаточно знать, что они существуют. Server Action — это функция с директивой «use server», которую можно безопасно вызывать с клиента, и Next.js под капотом превратит вызов в HTTP-запрос.
// app/actions.ts
"use server";
export async function saveProfile(formData: FormData) {
// Этот код выполнится на сервере, даже если функция
// вызвана из клиентского компонента
await db.user.update({ where: { id: 1 }, data: { ... } });
}Подробно — в модуле 6. Сейчас запомните: «нужен серверный код из клиента → Server Action».
#2.5. Suspense и стриминг
React Suspense позволяет показывать fallback-UI, пока асинхронный компонент загружает данные. В Next.js это работает со стримингом — сервер отправляет HTML частями по мере готовности.
#2.6. Практика: параллельная загрузка данных
Шаг 1. Создайте app/dashboard/page.tsx:
// app/dashboard/page.tsx
import { Suspense } from "react";
// Имитация медленных запросов
async function getStats() {
await new Promise((r) => setTimeout(r, 1000));
return { users: 1234, orders: 567, revenue: "1.2M ₽" };
}
async function getRecentOrders() {
await new Promise((r) => setTimeout(r, 2000));
return [
{ id: 1, product: "Ноутбук", total: "89 990 ₽" },
{ id: 2, product: "Мышь", total: "2 990 ₽" },
];
}
async function getNotifications() {
await new Promise((r) => setTimeout(r, 3000));
return [
"Новый заказ #123",
"Отзыв от клиента",
"Товар заканчивается",
];
}
async function StatsBlock() {
const stats = await getStats();
return (
<div style={{
border: "1px solid green",
padding: "1rem",
borderRadius: "8px",
}}>
<h3>📊 Статистика</h3>
<p>Пользователи: {stats.users}</p>
<p>Заказы: {stats.orders}</p>
<p>Выручка: {stats.revenue}</p>
</div>
);
}
async function RecentOrders() {
const orders = await getRecentOrders();
return (
<div style={{
border: "1px solid blue",
padding: "1rem",
borderRadius: "8px",
}}>
<h3>🛒 Последние заказы</h3>
<ul>
{orders.map((o) => (
<li key={o.id}>{o.product} — {o.total}</li>
))}
</ul>
</div>
);
}
async function Notifications() {
const notifications = await getNotifications();
return (
<div style={{
border: "1px solid orange",
padding: "1rem",
borderRadius: "8px",
}}>
<h3>🔔 Уведомления</h3>
<ul>
{notifications.map((n, i) => (
<li key={i}>{n}</li>
))}
</ul>
</div>
);
}
export default function DashboardPage() {
return (
<div>
<h1>Дашборд</h1>
<div style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: "1rem",
}}>
<Suspense fallback={<div>⏳ Загрузка статистики...</div>}>
<StatsBlock />
</Suspense>
<Suspense fallback={<div>⏳ Загрузка заказов...</div>}>
<RecentOrders />
</Suspense>
<Suspense fallback={<div>⏳ Загрузка уведомлений...</div>}>
<Notifications />
</Suspense>
</div>
</div>
);
}Шаг 2. Проверьте:
- Откройте http://localhost:3000/dashboard
- Через 1 секунду появится статистика
- Через 2 секунды — заказы
- Через 3 секунды — уведомления
- Каждый блок загружается независимо
#Что видит пользователь — пошаговая таблица
Чтобы наглядно понять, что даёт Suspense, посмотрите на хронологию загрузки страницы дашборда:
| Время | Что видит пользователь |
|---|---|
| 0,0 с | Каркас страницы: html, body и заголовок «Дашборд» уже видны, а на месте трёх блоков — надписи «Загрузка …», это fallback из Suspense |
| 1,0 с | Появилась статистика; заказы и уведомления ещё крутятся |
| 2,0 с | Появились заказы; уведомления ещё крутятся |
| 3,0 с | Появились уведомления — вся страница на месте |
Без Suspense пользователь видел бы пустой экран все 3 секунды и затем — всё разом. Suspense создаёт ощущение отзывчивого интерфейса, даже когда часть данных ещё загружается.
#Новинки React 19.2 — для тех, кто хочет копнуть глубже (опционально)
React 19.2 (входит в Next.js 16) добавил несколько API, связанных с Suspense:
- <ViewTransition> — анимированные переходы между состояниями. Подробно — в модуле 14.
- <Activity> — позволяет «приостанавливать» части UI без размонтирования (сохраняет состояние). Полезно для табов и модальных окон. Подробно — в модуле 14.
- useEffectEvent — вынос «нереактивной» логики из useEffect. Помогает избежать лишних эффектов. Подробнее: react.dev.
Эти возможности необязательны для базовой работы с Suspense — приводятся для расширения кругозора.
#2.7. Практика: комплексная страница
Создадим страницу профиля, которая объединяет все изученные паттерны.
Шаг 1. Создайте app/components/EditProfileForm.tsx (клиентский):
// app/components/EditProfileForm.tsx
"use client";
import { useState } from "react";
export function EditProfileForm({
initialName,
initialBio,
}: {
initialName: string;
initialBio: string;
}) {
const [name, setName] = useState(initialName);
const [bio, setBio] = useState(initialBio);
const [saved, setSaved] = useState(false);
function handleSave() {
// В будущем здесь будет Server Action
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}
return (
<div style={{
border: "1px solid #ddd",
padding: "1rem",
borderRadius: "8px",
}}>
<h3>Редактировать профиль</h3>
<div style={{ marginBottom: "0.5rem" }}>
<label>Имя: </label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div style={{ marginBottom: "0.5rem" }}>
<label>О себе: </label>
<textarea
value={bio}
onChange={(e) => setBio(e.target.value)}
/>
</div>
<button onClick={handleSave}>Сохранить</button>
{saved && (
<span style={{ color: "green", marginLeft: "1rem" }}>
✅ Сохранено!
</span>
)}
</div>
);
}Шаг 2. Создайте app/profile/page.tsx:
// app/profile/page.tsx (Server Component)
import { Suspense } from "react";
import { EditProfileForm } from "../components/EditProfileForm";
async function getUserInfo() {
await new Promise((r) => setTimeout(r, 1000));
return { name: "Алексей", email: "alex@example.com", bio: "Разработчик" };
}
async function getUserOrders() {
await new Promise((r) => setTimeout(r, 2000));
return [
{ id: 1, item: "Next.js курс", date: "2024-01-15" },
{ id: 2, item: "TypeScript книга", date: "2024-02-20" },
];
}
async function UserInfo() {
const user = await getUserInfo();
return (
<div style={{
border: "1px solid #4CAF50",
padding: "1rem",
borderRadius: "8px",
}}>
<h3>👤 Информация</h3>
<p>Имя: {user.name}</p>
<p>Email: {user.email}</p>
</div>
);
}
async function UserOrders() {
const orders = await getUserOrders();
return (
<div style={{
border: "1px solid #2196F3",
padding: "1rem",
borderRadius: "8px",
}}>
<h3>📦 Заказы</h3>
<ul>
{orders.map((o) => (
<li key={o.id}>{o.item} — {o.date}</li>
))}
</ul>
</div>
);
}
export default async function ProfilePage() {
// Получаем данные для формы (серверный компонент)
const user = await getUserInfo();
return (
<div>
<h1>Профиль</h1>
<div style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "1rem",
}}>
<Suspense fallback={<div>⏳ Загрузка информации...</div>}>
<UserInfo />
</Suspense>
<Suspense fallback={<div>⏳ Загрузка заказов...</div>}>
<UserOrders />
</Suspense>
</div>
<div style={{ marginTop: "1rem" }}>
{/* Клиентский компонент с данными от сервера */}
<EditProfileForm
initialName={user.name}
initialBio={user.bio}
/>
</div>
</div>
);
}Шаг 3. Проверьте:
- Откройте http://localhost:3000/profile
- Информация появляется примерно через 2 секунды, заказы — через 3: сначала страница ждёт getUserInfo() для формы, и только потом начинают работать границы Suspense
- Форма редактирования работает (интерактивная)
- Форма получила начальные данные от серверного компонента
#Итоги модуля 2
- Все компоненты в App Router — серверные по умолчанию
- "use client" делает компонент клиентским (интерактивным)
- Минимизируйте "use client" — выносите интерактивность в маленькие компоненты
- Серверные компоненты могут быть async и напрямую работать с БД/файлами
- Паттерн children позволяет вставлять серверный контент в клиентские компоненты — серверный компонент передаётся как children из серверного родителя, а не импортируется в файл с «use client»
- Suspense + стриминг = параллельная загрузка и мгновенный отклик
- После «use client» все импорты файла попадают в клиентский бандл — поэтому минимизируйте клиентскую часть.
- «use client» ≠ «use server» — первое размечает клиентский файл, второе размечает Server Action.
- Ошибка с упоминанием hook/window/document означает, что компонент должен быть клиентским; с упоминанием fs/database — что серверным.
- Практический результат модуля: создание серверных страниц с прямым доступом к данным, вынос интерактивных частей в маленькие клиентские компоненты, использование Suspense для медленно загружаемых блоков.
#Домашнее задание
Задания собираются в одну страницу — табло вылетов небольшого аэропорта, где серверная часть готовит данные, а клиентская отвечает только за интерактивность.
- Создайте серверную страницу app/flights/page.tsx: асинхронный компонент читает список рейсов из файла data/flights.json через readFile из fs/promises, путь строит через path.join(process.cwd(), ...) и выводит таблицу рейсов; добавьте console.log с числом рейсов и убедитесь, что он появляется в терминале сервера; в консоли браузера при npm run dev он тоже виден, но с серой плашкой Server — это пересылка серверных логов для отладки, а не признак того, что код выполнился в браузере, и в production-сборке её уже нет.
- Вынесите интерактивность в отдельный файл app/components/TerminalFilter.tsx с директивой "use client": компонент получает готовый список рейсов пропсом от серверной страницы, хранит выбранный терминал в useState и показывает только подходящие рейсы; сама страница при этом остаётся серверной, директивы "use client" в ней быть не должно.
- Разбейте страницу на независимые серверные блоки с разным временем загрузки: сводка по задержкам (1 секунда) и погода в аэропорту (2 секунды), каждый вынесен в свой async-компонент и обёрнут в собственный Suspense со своим fallback-скелетоном; проверьте по секундомеру, что страница показывается сразу, а общее время равно самому медленному блоку, а не сумме блоков.
- Примените паттерн children: сделайте клиентский компонент-аккордеон app/components/Collapsible.tsx, который хранит состояние «открыт или закрыт» в useState и выводит children, и передайте внутрь него из серверной страницы серверный блок с расписанием на завтра; серверный блок не должен импортироваться в файл с "use client" — иначе он попадёт в клиентский бандл.
- (со звёздочкой) Сделайте сортировку табло по времени вылета и по номеру рейса, не обращаясь к серверу: серверный компонент передаёт клиентскому массив объектов, где рядом с готовым узлом строки лежат поля для сортировки, а клиентский компонент только меняет порядок и хранит выбранный вариант в useState; проверьте на вкладке Network, что переключение сортировки не порождает новых запросов, и объясните, почему при таком устройстве код чтения файла не попадает в клиентский бандл.