Перейти к содержимому
·28 мин чтения

React Server Components: ментальные модели, паттерны и подводные камни

Практическое руководство по React Server Components, которое я хотел бы иметь в начале. Ментальные модели, реальные паттерны, проблема границ и ошибки, которые я совершил, чтобы тебе не пришлось.

Поделиться:X / TwitterLinkedIn

Первые три месяца с React Server Components я был уверен, что понимаю их. Читал RFC, смотрел доклады с конференций, собрал пару демо-приложений. И с уверенностью заблуждался практически во всём.

Проблема не в том, что RSC — это сложно. Проблема в том, что ментальная модель по-настоящему отличается от всего, что мы делали в React раньше, и каждый — включая меня — пытается натянуть её на старые рельсы. «Это как SSR». Нет. «Это как PHP». Ближе, но нет. «Это просто компоненты, которые работают на сервере». Технически верно, практически бесполезно.

Дальше — всё, что мне реально нужно было знать, написано так, как я хотел бы, чтобы кто-то объяснил это мне. Не теоретическая версия. Та, где ты в 11 вечера пялишься на ошибку сериализации и хочешь понять почему.

Ментальная модель, которая реально работает#

Забудь на секунду всё, что знаешь о рендеринге в React. Вот новая картина.

В традиционном React (клиентском) всё дерево компонентов отправляется в браузер как JavaScript. Браузер скачивает его, парсит, выполняет и рендерит результат. Каждый компонент — будь то интерактивная форма на 200 строк или статический абзац текста — проходит через один и тот же пайплайн.

React Server Components разделяют это на два мира:

Server Components работают на сервере. Они выполняются один раз, создают свой вывод и отправляют результат клиенту — не код. Браузер никогда не видит функцию компонента, не скачивает его зависимости, не перерендеривает его.

Client Components работают как традиционный React. Они отправляются в браузер, гидрируются, хранят стейт, обрабатывают события. Это тот React, который ты уже знаешь.

Ключевое осознание, которое мне стыдно долго не приходило: Server Components — это дефолт. В Next.js App Router каждый компонент — это Server Component, пока ты явно не переключишь его на клиент с помощью "use client". Это противоположность тому, к чему мы привыкли, и это меняет подход к композиции.

Водопад рендеринга#

Вот что реально происходит, когда пользователь запрашивает страницу:

1. Request hits the server
2. Server executes Server Components top-down
3. When a Server Component hits a "use client" boundary,
   it stops — that subtree will render on the client
4. Server Components produce RSC Payload (a special format)
5. RSC Payload streams to the client
6. Client renders Client Components, stitching them into
   the server-rendered tree
7. Hydration makes Client Components interactive

Шаг 4 — это место, где живёт большинство путаницы. RSC Payload — это не HTML. Это специальный потоковый формат, который описывает дерево компонентов — что отрендерил сервер, где клиент должен подхватить, и какие пропсы передать через границу.

Выглядит это примерно так (упрощённо):

M1:{"id":"./src/components/Counter.tsx","chunks":["272:static/chunks/272.js"],"name":"Counter"}
S0:"$Sreact.suspense"
J0:["$","div",null,{"children":[["$","h1",null,{"children":"Welcome"}],["$","$L1",null,{"initialCount":0}]]}]

Запоминать этот формат не нужно. Но понимание того, что он существует — что между сервером и клиентом есть слой сериализации — сэкономит тебе часы дебаггинга. Каждый раз, когда ты получаешь ошибку «Props must be serializable», это потому что что-то, что ты передаёшь, не может пережить эту трансляцию.

Что значит «работает на сервере» на самом деле#

Когда я говорю, что Server Component «работает на сервере», я имею в виду буквально. Функция компонента выполняется в Node.js (или Edge runtime). Это значит, что ты можешь:

tsx
// app/dashboard/page.tsx — this is a Server Component by default
import { db } from "@/lib/database";
import { headers } from "next/headers";
 
export default async function DashboardPage() {
  const headerList = await headers();
  const userId = headerList.get("x-user-id");
 
  // Direct database query. No API route needed.
  const user = await db.user.findUnique({
    where: { id: userId },
  });
 
  const recentOrders = await db.order.findMany({
    where: { userId: user.id },
    orderBy: { createdAt: "desc" },
    take: 10,
  });
 
  return (
    <div>
      <h1>Welcome back, {user.name}</h1>
      <OrderList orders={recentOrders} />
    </div>
  );
}

Никакого useEffect. Никакого управления состоянием загрузки. Никакого API-роута для склейки. Компонент и есть слой данных. Это главная победа RSC, и это именно то, что поначалу вызывало наибольший дискомфорт, потому что я всё время думал «а где же разделение?»

Разделение — это граница "use client". Всё, что выше неё — сервер. Всё, что ниже — клиент. Вот и вся твоя архитектура.

Граница сервер/клиент#

Это то место, где понимание большинства людей ломается, и где я провёл большую часть своего времени на дебаг в первые месяцы.

Директива "use client"#

Директива "use client" в начале файла помечает всё, что экспортируется из этого файла, как Client Component. Это аннотация уровня модуля, а не компонента.

tsx
// src/components/Counter.tsx
"use client";
 
import { useState } from "react";
 
// This entire file is now "client territory"
export function Counter({ initialCount }: { initialCount: number }) {
  const [count, setCount] = useState(initialCount);
 
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}
 
// This is ALSO a Client Component because it's in the same file
export function ResetButton({ onReset }: { onReset: () => void }) {
  return <button onClick={onReset}>Reset</button>;
}

Частая ошибка: поставить "use client" в barrel-файл (index.ts), который реэкспортирует всё подряд. Поздравляю, ты только что сделал всю библиотеку компонентов клиентской. Я видел, как команды случайно отправляли 200 КБ JavaScript таким образом.

Что проходит через границу#

Вот правило, которое тебя спасёт: всё, что пересекает границу сервер-клиент, должно быть сериализуемо в JSON.

Что сериализуется:

  • Строки, числа, булевы, null, undefined
  • Массивы и простые объекты (содержащие сериализуемые значения)
  • Даты (сериализуются как ISO-строки)
  • Server Components (как JSX — до этого мы ещё доберёмся)
  • FormData
  • Типизированные массивы, ArrayBuffer

Что НЕ сериализуется:

  • Функции (включая обработчики событий)
  • Классы (инстансы пользовательских классов)
  • Символы
  • DOM-узлы
  • Стримы (в большинстве контекстов)

Это значит, что так делать нельзя:

tsx
// app/page.tsx (Server Component)
import { ItemList } from "@/components/ItemList"; // Client Component
 
export default async function Page() {
  const items = await getItems();
 
  return (
    <ItemList
      items={items}
      // ERROR: Functions are not serializable
      onItemClick={(id) => console.log(id)}
      // ERROR: Class instances are not serializable
      formatter={new Intl.NumberFormat("en-US")}
    />
  );
}

Решение — не делать страницу Client Component. Решение — протолкнуть интерактивность вниз, а получение данных — вверх:

tsx
// app/page.tsx (Server Component)
import { ItemList } from "@/components/ItemList";
 
export default async function Page() {
  const items = await getItems();
 
  // Only pass serializable data
  return <ItemList items={items} locale="en-US" />;
}
tsx
// src/components/ItemList.tsx (Client Component)
"use client";
 
import { useState, useMemo } from "react";
 
interface Item {
  id: string;
  name: string;
  price: number;
}
 
export function ItemList({ items, locale }: { items: Item[]; locale: string }) {
  const [selectedId, setSelectedId] = useState<string | null>(null);
 
  // Create the formatter on the client side
  const formatter = useMemo(
    () => new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }),
    [locale]
  );
 
  return (
    <ul>
      {items.map((item) => (
        <li
          key={item.id}
          onClick={() => setSelectedId(item.id)}
          className={selectedId === item.id ? "selected" : ""}
        >
          {item.name} — {formatter.format(item.price)}
        </li>
      ))}
    </ul>
  );
}

Заблуждение об «островах»#

Поначалу я думал о Client Components как об «островах» — маленьких интерактивных кусочках в море серверного контента. Это отчасти верно, но упускает критическую деталь: Client Component может рендерить Server Components, если они переданы как children или пропсы.

Это значит, что граница — не жёсткая стена. Она больше похожа на мембрану. Серверный контент может проходить через Client Components с помощью паттерна children. Мы разберём это подробнее в разделе о композиции.

Паттерны получения данных#

RSC фундаментально меняет получение данных. Больше никакого useEffect + useState + состояний загрузки для данных, которые известны на этапе рендеринга. Но у новых паттернов есть свои подводные камни.

Базовый fetch с кэшированием#

В Server Component ты просто делаешь fetch. Next.js расширяет глобальный fetch, добавляя кэширование:

tsx
// app/products/page.tsx
export default async function ProductsPage() {
  // Cached by default — same URL returns cached result
  const res = await fetch("https://api.example.com/products");
  const products = await res.json();
 
  return (
    <div>
      {products.map((product: Product) => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

Управляй поведением кэширования явно:

tsx
// Revalidate every 60 seconds (ISR-like behavior)
const res = await fetch("https://api.example.com/products", {
  next: { revalidate: 60 },
});
 
// No caching — always fresh data
const res = await fetch("https://api.example.com/user/profile", {
  cache: "no-store",
});
 
// Cache with tags for on-demand revalidation
const res = await fetch("https://api.example.com/products", {
  next: { tags: ["products"] },
});

Затем можно ревалидировать по тегу из Server Action:

tsx
"use server";
 
import { revalidateTag } from "next/cache";
 
export async function refreshProducts() {
  revalidateTag("products");
}

Параллельное получение данных#

Самая частая ошибка производительности, которую я вижу: последовательное получение данных, когда параллельное работало бы отлично.

Плохо — последовательно (водопады):

tsx
// app/dashboard/page.tsx — DON'T DO THIS
export default async function Dashboard() {
  const user = await getUser();           // 200ms
  const orders = await getOrders();       // 300ms
  const notifications = await getNotifications(); // 150ms
  // Total: 650ms — each waits for the previous one
 
  return (
    <div>
      <UserInfo user={user} />
      <OrderList orders={orders} />
      <NotificationBell notifications={notifications} />
    </div>
  );
}

Хорошо — параллельно:

tsx
// app/dashboard/page.tsx — DO THIS
export default async function Dashboard() {
  // All three fire simultaneously
  const [user, orders, notifications] = await Promise.all([
    getUser(),        // 200ms
    getOrders(),      // 300ms (runs in parallel)
    getNotifications(), // 150ms (runs in parallel)
  ]);
  // Total: ~300ms — limited by the slowest request
 
  return (
    <div>
      <UserInfo user={user} />
      <OrderList orders={orders} />
      <NotificationBell notifications={notifications} />
    </div>
  );
}

Ещё лучше — параллельно с независимыми Suspense-границами:

tsx
// app/dashboard/page.tsx — BEST
import { Suspense } from "react";
 
export default function Dashboard() {
  // Note: this component is NOT async — it delegates to children
  return (
    <div>
      <Suspense fallback={<UserInfoSkeleton />}>
        <UserInfo />
      </Suspense>
      <Suspense fallback={<OrderListSkeleton />}>
        <OrderList />
      </Suspense>
      <Suspense fallback={<NotificationsSkeleton />}>
        <Notifications />
      </Suspense>
    </div>
  );
}
 
// Each component fetches its own data
async function UserInfo() {
  const user = await getUser();
  return <div>{user.name}</div>;
}
 
async function OrderList() {
  const orders = await getOrders();
  return <ul>{orders.map(o => <li key={o.id}>{o.title}</li>)}</ul>;
}
 
async function Notifications() {
  const notifications = await getNotifications();
  return <span>({notifications.length})</span>;
}

Последний паттерн — самый мощный, потому что каждая секция загружается независимо. Пользователь видит контент по мере его появления, а не по принципу «всё или ничего». Быстрые секции не ждут медленных.

Дедупликация запросов#

Next.js автоматически дедуплицирует вызовы fetch с одинаковым URL и параметрами в рамках одного рендеринга. Это значит, что тебе не нужно поднимать получение данных наверх, чтобы избежать повторных запросов:

tsx
// Both of these components can fetch the same URL
// and Next.js will only make ONE actual HTTP request
 
async function Header() {
  const user = await fetch("/api/user").then(r => r.json());
  return <nav>Welcome, {user.name}</nav>;
}
 
async function Sidebar() {
  // Same URL — automatically deduped, not a second request
  const user = await fetch("/api/user").then(r => r.json());
  return <aside>Role: {user.role}</aside>;
}

Важный нюанс: это работает только с fetch. Если ты используешь ORM или клиент базы данных напрямую, нужна функция React cache():

tsx
import { cache } from "react";
import { db } from "@/lib/database";
 
// Wrap your data function with cache()
// Now multiple calls in the same render = one actual query
export const getUser = cache(async (userId: string) => {
  return db.user.findUnique({ where: { id: userId } });
});

cache() дедуплицирует на время жизни одного серверного запроса. Это не постоянный кэш — это мемоизация по запросу. Когда запрос завершён, кэшированные значения отправляются в сборщик мусора.

Паттерны композиции компонентов#

Вот тут RSC становится по-настоящему элегантным, когда ты понимаешь паттерны. И по-настоящему запутанным, пока не понимаешь.

Паттерн «children как отверстие»#

Это самый важный паттерн композиции в RSC, и мне потребовались недели, чтобы его по-настоящему оценить. Вот проблема: у тебя есть Client Component, который обеспечивает какой-то лейаут или интерактивность, и ты хочешь рендерить внутри него Server Components.

Ты не можешь импортировать Server Component в файл Client Component. Как только ты добавляешь "use client", всё в этом модуле — клиентское. Но ты можешь передать Server Components как children:

tsx
// src/components/Sidebar.tsx — Client Component
"use client";
 
import { useState } from "react";
 
export function Sidebar({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(true);
 
  return (
    <aside className={isOpen ? "w-64" : "w-0"}>
      <button onClick={() => setIsOpen(!isOpen)}>
        {isOpen ? "Close" : "Open"}
      </button>
      {isOpen && (
        <div className="sidebar-content">
          {/* These children can be Server Components! */}
          {children}
        </div>
      )}
    </aside>
  );
}
tsx
// app/dashboard/layout.tsx — Server Component
import { Sidebar } from "@/components/Sidebar";
import { NavigationLinks } from "@/components/NavigationLinks"; // Server Component
import { UserProfile } from "@/components/UserProfile"; // Server Component
 
export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="flex">
      <Sidebar>
        {/* These are Server Components, passed through a Client Component */}
        <UserProfile />
        <NavigationLinks />
      </Sidebar>
      <main>{children}</main>
    </div>
  );
}

Почему это работает? Потому что Server Components (UserProfile, NavigationLinks) рендерятся на сервере первыми, затем их результат (RSC payload) передаётся как children в Client Component. Client Component не обязан знать, что они были Server Components — он просто получает предотрендеренные React-узлы.

Думай о children как об «отверстии» в Client Component, через которое может проходить серверный контент.

Передача Server Components как пропсов#

Паттерн children обобщается на любой проп, принимающий React.ReactNode:

tsx
// src/components/TabLayout.tsx — Client Component
"use client";
 
import { useState } from "react";
 
interface TabLayoutProps {
  tabs: { label: string; content: React.ReactNode }[];
}
 
export function TabLayout({ tabs }: TabLayoutProps) {
  const [activeTab, setActiveTab] = useState(0);
 
  return (
    <div>
      <div className="tab-bar" role="tablist">
        {tabs.map((tab, i) => (
          <button
            key={i}
            role="tab"
            aria-selected={i === activeTab}
            onClick={() => setActiveTab(i)}
            className={i === activeTab ? "active" : ""}
          >
            {tab.label}
          </button>
        ))}
      </div>
      <div role="tabpanel">{tabs[activeTab].content}</div>
    </div>
  );
}
tsx
// app/settings/page.tsx — Server Component
import { TabLayout } from "@/components/TabLayout";
import { ProfileSettings } from "./ProfileSettings";   // Server Component — can fetch data
import { BillingSettings } from "./BillingSettings";    // Server Component — can fetch data
import { SecuritySettings } from "./SecuritySettings";  // Server Component — can fetch data
 
export default function SettingsPage() {
  return (
    <TabLayout
      tabs={[
        { label: "Profile", content: <ProfileSettings /> },
        { label: "Billing", content: <BillingSettings /> },
        { label: "Security", content: <SecuritySettings /> },
      ]}
    />
  );
}

Каждый компонент настроек может быть асинхронным Server Component, который получает свои данные. Client Component (TabLayout) просто обрабатывает переключение вкладок. Это невероятно мощный паттерн.

Асинхронные Server Components#

Server Components могут быть async. Это огромное дело, потому что это значит, что получение данных происходит во время рендеринга, а не как побочный эффект:

tsx
// This is valid and beautiful
async function BlogPost({ slug }: { slug: string }) {
  const post = await db.post.findUnique({ where: { slug } });
 
  if (!post) return notFound();
 
  const author = await db.user.findUnique({ where: { id: post.authorId } });
 
  return (
    <article>
      <h1>{post.title}</h1>
      <p>By {author.name}</p>
      <div dangerouslySetInnerHTML={{ __html: post.htmlContent }} />
    </article>
  );
}

Client Components не могут быть async. Если ты попытаешься сделать "use client" компонент асинхронным, React выбросит ошибку. Это жёсткое ограничение.

Suspense-границы: примитив стриминга#

Suspense — это то, как ты получаешь стриминг в RSC. Без Suspense-границ вся страница ждёт самого медленного асинхронного компонента. С ними каждая секция стримится независимо:

tsx
// app/page.tsx
import { Suspense } from "react";
import { HeroSection } from "@/components/HeroSection";
import { ProductGrid } from "@/components/ProductGrid";
import { ReviewCarousel } from "@/components/ReviewCarousel";
import { RecommendationEngine } from "@/components/RecommendationEngine";
 
export default function HomePage() {
  return (
    <main>
      {/* Static — renders immediately */}
      <HeroSection />
 
      {/* Fast data — shows quickly */}
      <Suspense fallback={<ProductGridSkeleton />}>
        <ProductGrid />
      </Suspense>
 
      {/* Medium speed — shows when ready */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <ReviewCarousel />
      </Suspense>
 
      {/* Slow (ML-powered) — shows last, doesn't block the rest */}
      <Suspense fallback={<RecommendationsSkeleton />}>
        <RecommendationEngine />
      </Suspense>
    </main>
  );
}

Пользователь мгновенно видит HeroSection, затем подтягивается ProductGrid, потом отзывы, потом рекомендации. Каждая Suspense-граница — это независимая точка стриминга.

Вложенные Suspense-границы тоже валидны и полезны:

tsx
<Suspense fallback={<DashboardSkeleton />}>
  <Dashboard>
    <Suspense fallback={<ChartSkeleton />}>
      <RevenueChart />
    </Suspense>
    <Suspense fallback={<TableSkeleton />}>
      <RecentTransactions />
    </Suspense>
  </Dashboard>
</Suspense>

Если Dashboard быстрый, а RevenueChart медленный, внешний Suspense резолвится первым (показывая шелл дашборда), а внутренний Suspense для графика резолвится позже.

Error Boundaries с Suspense#

Комбинируй Suspense с error.tsx для устойчивого UI:

tsx
// app/dashboard/error.tsx — Client Component (must be)
"use client";
 
export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div className="p-8 text-center">
      <h2>Something went wrong loading the dashboard</h2>
      <p className="text-gray-500">{error.message}</p>
      <button onClick={reset} className="mt-4 px-4 py-2 bg-blue-600 text-white rounded">
        Try again
      </button>
    </div>
  );
}

Файл error.tsx автоматически оборачивает соответствующий сегмент маршрута в React Error Boundary. Если какой-либо Server Component в этом сегменте бросает ошибку, вместо крэша всей страницы показывается UI ошибки.

Когда что использовать: дерево решений#

После создания нескольких продакшен-приложений с RSC я выработал чёткий фреймворк принятия решений. Вот мыслительный процесс, через который я прохожу для каждого компонента:

Начинай с Server Components (это дефолт)#

Каждый компонент должен быть Server Component, если нет конкретной причины, почему он не может. Это самое важное правило.

Сделай его Client Component, когда:#

1. Он использует API, доступные только в браузере

tsx
"use client";
// window, document, navigator, localStorage, etc.
function GeoLocation() {
  const [coords, setCoords] = useState<GeolocationCoordinates | null>(null);
 
  useEffect(() => {
    navigator.geolocation.getCurrentPosition(
      (pos) => setCoords(pos.coords)
    );
  }, []);
 
  return coords ? <p>Lat: {coords.latitude}</p> : <p>Loading location...</p>;
}

2. Он использует React-хуки, требующие стейта или эффектов

useState, useEffect, useReducer, useRef (для мутабельных рефов), useContext — любой из них требует "use client".

tsx
"use client";
function SearchBar() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebounce(query, 300);
 
  // This component MUST be a Client Component because it
  // uses useState and manages user input
  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="Search..."
    />
  );
}

3. Он подключает обработчики событий

onClick, onChange, onSubmit, onMouseEnter — любое интерактивное поведение означает клиентскую сторону.

4. Он использует клиентские библиотеки

Framer Motion, React Hook Form, Zustand, React Query (для клиентского получения данных), любую библиотеку графиков, рендерящую в canvas или интерактивный SVG.

Оставляй Server Component, когда:#

  • Он только отображает данные (без интерактивности)
  • Он получает данные из базы данных или API
  • Он обращается к бэкенд-ресурсам (файловая система, переменные окружения с секретами)
  • Он импортирует тяжёлые зависимости, которые клиенту не нужны (парсеры markdown, подсветка синтаксиса, библиотеки дат для форматирования)
  • Он рендерит статический или полустатический контент

Реальное принятие решений на практике#

Вот конкретный пример. Я создаю страницу продукта:

ProductPage (Server)
├── ProductBreadcrumbs (Server) — static navigation, no interactivity
├── ProductImageGallery (Client) — zoom, swipe, lightbox
├── ProductInfo (Server)
│   ├── ProductTitle (Server) — just text
│   ├── ProductPrice (Server) — formatted number, no interaction
│   └── AddToCartButton (Client) — onClick, manages cart state
├── ProductDescription (Server) — rendered markdown
├── Suspense
│   └── RelatedProducts (Server) — async data fetch, slow API
└── Suspense
    └── ProductReviews (Server)
        └── ReviewForm (Client) — form with validation

Обрати внимание на паттерн: шелл страницы и данные-тяжёлые части — это Server Components. Интерактивные острова (ImageGallery, AddToCartButton, ReviewForm) — это Client Components. Медленные секции (RelatedProducts, ProductReviews) обёрнуты в Suspense.

Это не теория. Именно так мои деревья компонентов выглядят в реальности.

Распространённые ошибки (я совершил их все)#

Ошибка 1: Сделать всё Client Component#

Путь наименьшего сопротивления при миграции с Pages Router или Create React App — налепить "use client" на всё подряд. Работает! Ничего не ломается! Ты также отправляешь всё дерево компонентов как JavaScript и получаешь ноль преимуществ RSC.

Я видел кодовые базы, где на root layout стоит "use client". В этот момент ты буквально запускаешь клиентское React-приложение с лишними шагами.

Решение: Начни с Server Components. Добавляй "use client" только когда компилятор скажет, что это нужно (потому что ты использовал хук или обработчик событий). Продвигай "use client" как можно ниже по дереву.

Ошибка 2: Прокидывание пропсов через границу#

tsx
// BAD: fetching data in a Server Component, then passing it through
// multiple Client Components
 
// app/page.tsx (Server)
export default async function Page() {
  const user = await getUser();
  const settings = await getSettings();
  const theme = await getTheme();
 
  return (
    <ClientShell user={user} settings={settings} theme={theme}>
      <ClientContent user={user} settings={settings}>
        <ClientWidget user={user} />
      </ClientContent>
    </ClientShell>
  );
}

Каждый кусок данных, который ты передаёшь через границу, сериализуется в RSC payload. Передаёшь один и тот же объект пять раз? Он в пэйлоаде пять раз. Я видел, как RSC payload раздувался до мегабайт из-за этого.

Решение: Используй композицию. Передавай Server Components как children вместо передачи данных как пропсов:

tsx
// GOOD: Server Components fetch their own data, pass through as children
 
// app/page.tsx (Server)
export default function Page() {
  return (
    <ClientShell>
      <UserInfo />      {/* Server Component — fetches its own data */}
      <Settings />      {/* Server Component — fetches its own data */}
      <ClientWidget>
        <UserAvatar />  {/* Server Component — fetches its own data */}
      </ClientWidget>
    </ClientShell>
  );
}

Ошибка 3: Не использовать Suspense#

Без Suspense Time to First Byte (TTFB) твоей страницы ограничен самым медленным запросом данных. У меня была страница дашборда, которая грузилась 4 секунды, потому что один запрос аналитики был медленным, хотя остальные данные страницы были готовы за 200 мс.

tsx
// BAD: everything waits for everything
export default async function Dashboard() {
  const stats = await getStats();         // 200ms
  const chart = await getChartData();     // 300ms
  const analytics = await getAnalytics(); // 4000ms ← blocks everything
 
  return (
    <div>
      <Stats data={stats} />
      <Chart data={chart} />
      <Analytics data={analytics} />
    </div>
  );
}
tsx
// GOOD: analytics loads independently
export default function Dashboard() {
  return (
    <div>
      <Suspense fallback={<StatsSkeleton />}>
        <Stats />
      </Suspense>
      <Suspense fallback={<ChartSkeleton />}>
        <Chart />
      </Suspense>
      <Suspense fallback={<AnalyticsSkeleton />}>
        <Analytics /> {/* Takes 4s but doesn't block the rest */}
      </Suspense>
    </div>
  );
}

Ошибка 4: Ошибки сериализации в рантайме#

Эта особенно болезненная, потому что ты часто не ловишь её до продакшена. Передаёшь что-то несериализуемое через границу и получаешь загадочную ошибку:

Error: Only plain objects, and a few built-ins, can be passed to
Client Components from Server Components. Classes or null prototypes
are not supported.

Частые виновники:

  • Передача объектов Date (используй .toISOString() вместо этого)
  • Передача Map или Set (конвертируй в массивы/объекты)
  • Передача инстансов классов из ORM (используй .toJSON() или спред в простые объекты)
  • Передача функций (перенеси логику в Client Component или используй Server Actions)
  • Передача результатов моделей Prisma с полями Decimal (конвертируй в number или string)
tsx
// BAD
const user = await prisma.user.findUnique({ where: { id } });
// user might have non-serializable fields (Decimal, BigInt, etc.)
return <ClientProfile user={user} />;
 
// GOOD
const user = await prisma.user.findUnique({ where: { id } });
const serializedUser = {
  id: user.id,
  name: user.name,
  email: user.email,
  balance: user.balance.toNumber(), // Decimal → number
  createdAt: user.createdAt.toISOString(), // Date → string
};
return <ClientProfile user={serializedUser} />;

Ошибка 5: Использовать Context для всего#

useContext работает только в Client Components. Если ты попытаешься использовать React context в Server Component, ничего не получится. Я видел, как люди делали всё приложение Client Component только ради theme context.

Решение: Для тем и другого глобального состояния используй CSS-переменные, установленные на серверной стороне, или функции cookies() / headers():

tsx
// app/layout.tsx (Server Component)
import { cookies } from "next/headers";
 
export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const cookieStore = await cookies();
  const theme = cookieStore.get("theme")?.value ?? "light";
 
  return (
    <html data-theme={theme} className={theme === "dark" ? "dark" : ""}>
      <body>{children}</body>
    </html>
  );
}

Для действительно клиентского состояния (токены авторизации, корзины покупок, данные в реальном времени) создай тонкий Client Component провайдер на подходящем уровне — не в корне:

tsx
// src/providers/CartProvider.tsx
"use client";
 
import { createContext, useContext, useState } from "react";
 
const CartContext = createContext<CartContextType | null>(null);
 
export function CartProvider({ children }: { children: React.ReactNode }) {
  const [items, setItems] = useState<CartItem[]>([]);
 
  return (
    <CartContext.Provider value={{ items, setItems }}>
      {children}
    </CartContext.Provider>
  );
}
 
export function useCart() {
  const context = useContext(CartContext);
  if (!context) throw new Error("useCart must be used within CartProvider");
  return context;
}
tsx
// app/shop/layout.tsx (Server Component)
import { CartProvider } from "@/providers/CartProvider";
 
export default function ShopLayout({ children }: { children: React.ReactNode }) {
  // CartProvider is a Client Component, but children flow through as server content
  return <CartProvider>{children}</CartProvider>;
}

Ошибка 6: Игнорировать влияние на размер бандла#

Одно из главных преимуществ RSC — код Server Component никогда не отправляется клиенту. Но над этим нужно думать сознательно. Если у тебя есть компонент, который использует парсер markdown на 50 КБ и просто отображает отрендеренный контент — он должен быть Server Component. Парсер остаётся на сервере, и клиенту уходит только HTML.

tsx
// Server Component — marked stays on the server
import { marked } from "marked"; // 50KB library — never ships to client
 
async function BlogContent({ slug }: { slug: string }) {
  const post = await getPost(slug);
  const html = marked(post.markdown);
 
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

Если бы ты сделал это Client Component, marked отправился бы в браузер. Вхолостую. Пользователь скачал бы 50 КБ JavaScript только для рендеринга контента, который мог быть HTML с самого начала.

Проверь свой бандл с помощью @next/bundle-analyzer. Результаты могут тебя удивить.

Стратегия кэширования#

Кэширование в Next.js 15+ значительно упрощено по сравнению с предыдущими версиями, но всё ещё есть отдельные слои, которые нужно понимать.

Функция cache() (React)#

React cache() предназначена для дедупликации на уровне запроса, а не для постоянного кэширования:

tsx
import { cache } from "react";
 
export const getCurrentUser = cache(async () => {
  const session = await getSession();
  if (!session) return null;
 
  return db.user.findUnique({ where: { id: session.userId } });
});
 
// Call this anywhere in your component tree during a single request.
// Only one actual database query will execute.

Она привязана к одному серверному запросу. Когда запрос завершён, кэшированное значение исчезает. Это мемоизация, а не кэширование.

unstable_cache (Next.js)#

Для постоянного кэширования между запросами используй unstable_cache (название со словом «unstable» навечно, но в продакшене работает отлично):

tsx
import { unstable_cache } from "next/cache";
 
const getCachedProducts = unstable_cache(
  async (categoryId: string) => {
    return db.product.findMany({
      where: { categoryId },
      include: { images: true },
    });
  },
  ["products-by-category"], // cache key prefix
  {
    revalidate: 3600, // revalidate every hour
    tags: ["products"], // for on-demand revalidation
  }
);
 
// Usage in a Server Component
async function ProductGrid({ categoryId }: { categoryId: string }) {
  const products = await getCachedProducts(categoryId);
  return <Grid items={products} />;
}

Для инвалидации:

tsx
"use server";
 
import { revalidateTag } from "next/cache";
 
export async function onProductUpdate() {
  revalidateTag("products");
}

Статический vs динамический рендеринг#

Next.js решает, является ли маршрут статическим или динамическим, на основе того, что ты в нём используешь:

Статический (рендерится во время сборки, кэшируется):

  • Нет динамических функций (cookies(), headers(), searchParams)
  • Все вызовы fetch имеют включённое кэширование
  • Нет export const dynamic = "force-dynamic"

Динамический (рендерится при каждом запросе):

  • Использует cookies(), headers() или searchParams
  • Использует fetch с cache: "no-store"
  • Имеет export const dynamic = "force-dynamic"
  • Использует connection() или after() из next/server

Ты можешь проверить, какие маршруты статические, а какие динамические, запустив next build — внизу показывается легенда:

Route (app)                    Size     First Load JS
┌ ○ /                          5.2 kB   92 kB
├ ○ /about                     1.1 kB   88 kB
├ ● /blog/[slug]               2.3 kB   89 kB
├ λ /dashboard                 4.1 kB   91 kB
└ λ /api/products              0 B      87 kB

○ Static   ● SSG   λ Dynamic

Иерархия кэширования#

Думай о кэшировании как о слоях:

1. React cache()          — per-request, in-memory, automatic dedup
2. fetch() cache          — cross-request, automatic for GET requests
3. unstable_cache()       — cross-request, for non-fetch operations
4. Full Route Cache       — rendered HTML cached at build/revalidation time
5. Router Cache (client)  — in-browser cache of visited routes

Каждый слой служит своей цели. Тебе не всегда нужны все, но понимание того, какой из них активен, помогает дебажить проблемы типа «почему мои данные не обновляются?».

Реальная стратегия кэширования#

Вот что я на самом деле делаю в продакшене:

tsx
// lib/data/products.ts
 
import { cache } from "react";
import { unstable_cache } from "next/cache";
import { db } from "@/lib/database";
 
// Per-request dedup: call this multiple times in one render,
// only one DB query runs
export const getProductById = cache(async (id: string) => {
  return db.product.findUnique({
    where: { id },
    include: { category: true, images: true },
  });
});
 
// Cross-request cache: results persist across requests,
// revalidate every 5 minutes or on-demand via tag
export const getPopularProducts = unstable_cache(
  async () => {
    return db.product.findMany({
      orderBy: { salesCount: "desc" },
      take: 20,
      include: { images: true },
    });
  },
  ["popular-products"],
  { revalidate: 300, tags: ["products"] }
);
 
// No caching: always fresh (for user-specific data)
export const getUserCart = cache(async (userId: string) => {
  // cache() here is only for per-request dedup, not persistence
  return db.cart.findUnique({
    where: { userId },
    include: { items: { include: { product: true } } },
  });
});

Правило большого пальца: публичные данные, которые меняются редко, получают unstable_cache. Пользовательские данные получают cache() только для дедупликации. Данные реального времени не кэшируются вообще.

Server Actions: мост обратно#

Server Actions заслуживают отдельного раздела, потому что они завершают историю RSC. Это то, как Client Components взаимодействуют с сервером без API-роутов.

tsx
// app/actions/newsletter.ts
"use server";
 
import { db } from "@/lib/database";
import { z } from "zod";
 
const emailSchema = z.string().email();
 
export async function subscribeToNewsletter(formData: FormData) {
  const email = formData.get("email");
 
  const result = emailSchema.safeParse(email);
  if (!result.success) {
    return { error: "Invalid email address" };
  }
 
  try {
    await db.subscriber.create({
      data: { email: result.data },
    });
    return { success: true };
  } catch (e) {
    if (e instanceof Error && e.message.includes("Unique constraint")) {
      return { error: "Already subscribed" };
    }
    return { error: "Something went wrong" };
  }
}
tsx
// src/components/NewsletterForm.tsx
"use client";
 
import { useActionState } from "react";
import { subscribeToNewsletter } from "@/app/actions/newsletter";
 
export function NewsletterForm() {
  const [state, formAction, isPending] = useActionState(
    async (_prev: { error?: string; success?: boolean } | null, formData: FormData) => {
      return subscribeToNewsletter(formData);
    },
    null
  );
 
  return (
    <form action={formAction}>
      <input
        type="email"
        name="email"
        required
        placeholder="you@example.com"
        disabled={isPending}
      />
      <button type="submit" disabled={isPending}>
        {isPending ? "Subscribing..." : "Subscribe"}
      </button>
      {state?.error && <p className="text-red-500">{state.error}</p>}
      {state?.success && <p className="text-green-500">Subscribed!</p>}
    </form>
  );
}

Server Actions — это ответ на вопрос «как мне мутировать данные?» в мире RSC. Они заменяют большинство API-роутов для отправки форм, мутаций и побочных эффектов.

Ключевые правила для Server Actions:

  • Всегда валидируй вход (функция вызываема с клиента — относись к ней как к API-эндпоинту)
  • Всегда возвращай сериализуемые данные
  • Они работают на сервере, поэтому ты можешь обращаться к базам данных, файловой системе, секретам
  • Они могут вызывать revalidatePath() или revalidateTag() для обновления кэшированных данных после мутаций

Паттерны миграции#

Если у тебя есть существующее React-приложение (Pages Router, Create React App, Vite), переход на RSC не обязан быть полной переделкой. Вот как я подхожу к этому.

Шаг 1: Составь карту компонентов#

Пройдись по дереву компонентов и классифицируй всё:

Component            State?  Effects?  Events?  → Decision
─────────────────────────────────────────────────────────
Header               No      No        No       → Server
NavigationMenu       No      No        Yes      → Client (mobile toggle)
Footer               No      No        No       → Server
BlogPost             No      No        No       → Server
SearchBar            Yes     Yes       Yes      → Client
ProductCard          No      No        Yes      → Client (onClick) or split
UserAvatar           No      No        No       → Server
CommentForm          Yes     Yes       Yes      → Client
Sidebar              Yes     No        Yes      → Client (collapse toggle)
MarkdownRenderer     No      No        No       → Server (big dependency win)
DataTable            Yes     Yes       Yes      → Client (sorting, filtering)

Шаг 2: Перемести получение данных вверх#

Самое большое архитектурное изменение — перенос получения данных из useEffect в компонентах в async Server Components. Именно здесь живёт основной объём работы по миграции.

До:

tsx
// Old pattern — data fetching in a Client Component
"use client";
 
function ProductPage({ id }: { id: string }) {
  const [product, setProduct] = useState<Product | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
 
  useEffect(() => {
    fetch(`/api/products/${id}`)
      .then(res => res.json())
      .then(data => setProduct(data))
      .catch(err => setError(err.message))
      .finally(() => setLoading(false));
  }, [id]);
 
  if (loading) return <Spinner />;
  if (error) return <Error message={error} />;
  if (!product) return <NotFound />;
 
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <AddToCartButton productId={product.id} />
    </div>
  );
}

После:

tsx
// New pattern — Server Component fetches, Client Component interacts
 
// app/products/[id]/page.tsx (Server Component)
import { db } from "@/lib/database";
import { notFound } from "next/navigation";
import { AddToCartButton } from "@/components/AddToCartButton";
 
export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await db.product.findUnique({ where: { id } });
 
  if (!product) notFound();
 
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <AddToCartButton productId={product.id} price={product.price} />
    </div>
  );
}

Никакого управления состоянием загрузки. Никакого состояния ошибки. Никакого useEffect. Фреймворк обрабатывает всё это через Suspense и error boundaries.

Шаг 3: Разделяй компоненты на границе интерактивности#

Многие компоненты в основном статические с маленькой интерактивной частью. Разделяй их:

До (один большой Client Component):

tsx
"use client";
 
function ProductCard({ product }: { product: Product }) {
  const [isFavorite, setIsFavorite] = useState(false);
 
  return (
    <div className="card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>{product.description}</p>
      <span>${product.price}</span>
      <button onClick={() => setIsFavorite(!isFavorite)}>
        {isFavorite ? "♥" : "♡"}
      </button>
    </div>
  );
}

После (Server Component с маленьким клиентским островом):

tsx
// ProductCard.tsx (Server Component)
import { FavoriteButton } from "./FavoriteButton";
 
function ProductCard({ product }: { product: Product }) {
  return (
    <div className="card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>{product.description}</p>
      <span>${product.price}</span>
      <FavoriteButton productId={product.id} />
    </div>
  );
}
tsx
// FavoriteButton.tsx (Client Component)
"use client";
 
import { useState } from "react";
 
export function FavoriteButton({ productId }: { productId: string }) {
  const [isFavorite, setIsFavorite] = useState(false);
 
  return (
    <button
      onClick={() => setIsFavorite(!isFavorite)}
      aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
    >
      {isFavorite ? "♥" : "♡"}
    </button>
  );
}

Изображение, заголовок, описание и цена теперь серверные. Только крошечная кнопка избранного — это Client Component. Меньше JavaScript, быстрее загрузка страницы.

Шаг 4: Конвертируй API-роуты в Server Actions#

Если у тебя есть API-роуты, которые существуют исключительно для обслуживания своего фронтенда (не для внешних потребителей), большинство из них можно превратить в Server Actions:

До:

tsx
// app/api/contact/route.ts
export async function POST(request: Request) {
  const body = await request.json();
  // validate, save to DB, send email
  return Response.json({ success: true });
}
 
// Client component
const response = await fetch("/api/contact", {
  method: "POST",
  body: JSON.stringify(formData),
});

После:

tsx
// app/actions/contact.ts
"use server";
 
export async function submitContactForm(formData: FormData) {
  // validate, save to DB, send email
  return { success: true };
}
 
// Client component — just call the function directly
import { submitContactForm } from "@/app/actions/contact";

Оставляй API-роуты для: вебхуков, внешних API-потребителей, всего, что требует кастомных HTTP-заголовков или статус-кодов, загрузки файлов со стримингом.

Шаг 5: Тестирование RSC-компонентов#

Тестирование Server Components требует немного другого подхода, поскольку они могут быть async:

tsx
// __tests__/ProductPage.test.tsx
import { render, screen } from "@testing-library/react";
import ProductPage from "@/app/products/[id]/page";
 
// Mock the database
vi.mock("@/lib/database", () => ({
  db: {
    product: {
      findUnique: vi.fn(),
    },
  },
}));
 
describe("ProductPage", () => {
  it("renders product details", async () => {
    const { db } = await import("@/lib/database");
    vi.mocked(db.product.findUnique).mockResolvedValue({
      id: "1",
      name: "Test Product",
      description: "A great product",
      price: 29.99,
    });
 
    // Server Components are async — await the JSX
    const jsx = await ProductPage({
      params: Promise.resolve({ id: "1" })
    });
    render(jsx);
 
    expect(screen.getByText("Test Product")).toBeInTheDocument();
    expect(screen.getByText("A great product")).toBeInTheDocument();
  });
});

Ключевое отличие: ты await-ишь функцию компонента, потому что она async. Затем рендеришь полученный JSX. Всё остальное работает так же, как традиционная React Testing Library.

Файловая структура для RSC-проектов#

Вот структура, к которой я пришёл после нескольких проектов. Она субъективная, но работает:

src/
├── app/
│   ├── layout.tsx              ← Root layout (Server Component)
│   ├── page.tsx                ← Home page (Server Component)
│   ├── (marketing)/            ← Route group for marketing pages
│   │   ├── about/page.tsx
│   │   └── pricing/page.tsx
│   ├── (app)/                  ← Route group for authenticated app
│   │   ├── layout.tsx          ← App shell with auth check
│   │   ├── dashboard/
│   │   │   ├── page.tsx
│   │   │   ├── loading.tsx     ← Suspense fallback for this route
│   │   │   └── error.tsx       ← Error boundary for this route
│   │   └── settings/
│   │       └── page.tsx
│   └── actions/                ← Server Actions
│       ├── auth.ts
│       └── products.ts
├── components/
│   ├── ui/                     ← Shared UI primitives (mostly Client)
│   │   ├── Button.tsx          ← "use client"
│   │   ├── Dialog.tsx          ← "use client"
│   │   └── Card.tsx            ← Server Component (just styling)
│   └── features/               ← Feature-specific components
│       ├── products/
│       │   ├── ProductGrid.tsx     ← Server (async, fetches data)
│       │   ├── ProductCard.tsx     ← Server (presentational)
│       │   ├── ProductSearch.tsx   ← Client (useState, input)
│       │   └── AddToCart.tsx       ← Client (onClick, mutation)
│       └── blog/
│           ├── PostList.tsx        ← Server (async, fetches data)
│           ├── PostContent.tsx     ← Server (markdown rendering)
│           └── CommentSection.tsx  ← Client (form, real-time)
├── lib/
│   ├── data/                   ← Data access layer
│   │   ├── products.ts         ← cache() wrapped DB queries
│   │   └── users.ts
│   ├── database.ts
│   └── utils.ts
└── providers/
    ├── ThemeProvider.tsx        ← "use client" — wraps parts that need theme
    └── CartProvider.tsx         ← "use client" — wraps shop section only

Ключевые принципы:

  • Server Components не имеют директивы — они по умолчанию
  • Client Components явно помечены — видно с первого взгляда
  • Получение данных живёт в lib/data/ — обёрнуто в cache() или unstable_cache
  • Server Actions живут в app/actions/ — расположены рядом с приложением, чётко отделены
  • Провайдеры оборачивают минимально необходимое — не всё приложение целиком

Итог#

React Server Components — это не просто новый API. Это другой способ думать о том, где выполняется код, где живут данные и как части соединяются. Сдвиг ментальной модели реален, и на это нужно время.

Но когда всё щёлкнет — когда ты перестанешь бороться с границей и начнёшь проектировать вокруг неё — ты получишь приложения, которые быстрее, проще и легче поддерживать, чем то, что мы строили раньше. Меньше JavaScript отправляется клиенту. Получение данных не требует церемоний. Дерево компонентов становится архитектурой.

Переход стоит того. Просто знай, что первые несколько проектов будут ощущаться некомфортно, и это нормально. Ты мучаешься не потому, что RSC плох. Ты мучаешься потому, что это по-настоящему ново.

Начинай с Server Components везде. Продвигай "use client" вниз к листьям. Оборачивай медленное в Suspense. Получай данные там, где они рендерятся. Комбинируй через children.

Вот и весь сценарий. Всё остальное — детали.

Похожие записи