К содержанию
Первый Коммит

Модуль 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 ComponentClient Component
РендерингТолько серверСервер (SSR) + клиент
JS-бандлНе попадаетПопадает
Хуки❌✅
onClick и др.❌✅
async/await✅ (в компоненте)❌ (только в useEffect)
Доступ к БД✅ напрямую❌ (через API/Action)
ДирективаПо умолчанию"use client"

#2.2. Практика: серверный и клиентский компоненты

Шаг 1. Создайте серверный компонент app/server-example/page.tsx:

JavaScript
// 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:

JavaScript
// 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:

JavaScript
// 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" ставится в начале файла

JavaScript
"use client";
// Все экспорты из этого файла — клиентские компоненты

import { useState } from "react";

export function MyComponent() { /* ... */ }
export function AnotherComponent() { /* ... */ } // тоже клиентский!

Правило 2: "use client" создаёт границу (boundary)

При добавлении "use client" все импортируемые в этот файл модули тоже становятся частью клиентского бандла:

JavaScript
"use client";

// utils.ts тоже попадёт в клиентский бандл!
import { formatDate } from "./utils";

// Но Server Component нельзя импортировать в Client Component
// import { ServerOnly } from "./ServerOnly"; // ❌ Ошибка!

Правило 3: Минимизируйте "use client"

Не делайте целую страницу клиентской. Выносите интерактивные части в отдельные маленькие компоненты:

❌ Плохо — вся страница клиентская:

JavaScript
// app/products/page.tsx
"use client"; // ← Вся страница в бандле!

import { useState } from "react";

export default function ProductsPage() {
  const [filter, setFilter] = useState("");
  // ... много статического контента
  // ... который мог бы быть серверным
}
JavaScript
// 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:

JavaScript
// Клиентский компонент
"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:

JavaScript
// 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:

JavaScript
// 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-запрос.

JavaScript
// 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:

JavaScript
// 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 (клиентский):

JavaScript
// 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:

JavaScript
// 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, что переключение сортировки не порождает новых запросов, и объясните, почему при таком устройстве код чтения файла не попадает в клиентский бандл.

Войдите, чтобы отмечать пройденные модули и видеть прогресс по курсу.

Введите запрос: название модуля, тема или раздел.

Стрелки — выбор, Enter — открыть. Ищет по названиям модулей, темам и разделам.