Перейти до вмісту
·17 хв читання

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

Практичний гайд по React Server Components, який я хотів би мати на початку. Ментальні моделі, реальні патерни, проблема меж та помилки, які я зробив, щоб тобі не довелося.

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

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

Проблема не в тому, що RSC складні. А в тому, що ментальна модель справді відрізняється від усього, що ми робили в React раніше, і всі — включаючи мене — намагаються впхнути її в стару коробку. «Це як SSR». Ні. «Це як PHP». Ближче, але ні. «Це просто компоненти, що виконуються на сервері». Технічно правда, практично марно.

Далі — все, що мені справді потрібно було знати, написане так, як я хотів би, щоб мені хтось пояснив. Не теоретична версія. Та, де ти пильно дивишся на помилку серіалізації о 23:00 і потрібно зрозуміти чому.

Ментальна модель, що справді працює#

Забудь на мить усе, що знаєш про рендеринг 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". Все над нею — сервер. Все під нею — клієнт. Ось твоя архітектура.

Межа Server/Client#

Тут розуміння більшості людей ламається, і тут я провів більшу частину свого часу дебагу в перші місяці.

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

"use client" — це не прапорець «зроби цей компонент клієнтським». Це оголошення межі. Воно каже: «цей файл і все, що він імпортує, є частиною клієнтського пакета». Це тонка, але критична різниця.

tsx
// components/Counter.tsx
"use client";
 
import { useState } from "react";
 
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>
  );
}

Коли Server Component рендерить <Counter initialCount={0} />, ось що відбувається:

  1. Сервер бачить, що Counter — це Client Component (через "use client")
  2. Замість виконання він записує посилання в RSC Payload: «тут буде Counter з цими пропсами»
  3. Клієнт отримує це посилання, завантажує код Counter і рендерить його

Сервер ніколи не виконує Client Component. Він лише передає заповнювач і пропси.

Правила серіалізації#

Це точний момент, де більшість людей вперше стикаються з RSC-помилками. Пропси, що перетинають межу server→client, мають бути серіалізовними:

tsx
// ✅ These props work across the boundary
<ClientComponent
  name="John"                    // string
  count={42}                     // number
  isActive={true}                // boolean
  items={["a", "b", "c"]}       // array of primitives
  config={{ theme: "dark" }}     // plain object
  createdAt={new Date()}         // Date (serialized specially)
  id={null}                      // null
/>
 
// ❌ These props CANNOT cross the boundary
<ClientComponent
  onClick={() => doSomething()}  // Function — not serializable
  ref={myRef}                    // Ref — not serializable
  component={MyComponent}        // React component — not serializable
  db={databaseConnection}        // Complex object — not serializable
  stream={readableStream}        // Stream — not serializable
/>

Правило просте: якщо JSON.stringify з ним впорається (плюс кілька спеціальних випадків, як Date), воно може перетнути межу. Якщо ні — ні.

Найпоширеніша помилка, яку я бачу: передача функцій-обробників із Server Component до Client Component. Ти не можеш цього зробити. Серверна функція не може існувати на клієнті — немає серверного контексту для її виконання.

Як насправді працює композиція#

Ось патерн, який змусив усе клацнути для мене. Server Components можуть рендерити Client Components. Client Components можуть рендерити Server Components як children. Але Client Components не можуть імпортувати Server Components.

tsx
// ✅ This works — Server Component renders Client Component
// app/page.tsx (Server Component)
import { InteractivePanel } from "@/components/InteractivePanel";
import { db } from "@/lib/database";
 
export default async function Page() {
  const data = await db.analytics.getStats();
 
  return (
    <div>
      <h1>Dashboard</h1>
      <InteractivePanel initialData={data} />
    </div>
  );
}
tsx
// ✅ This works — Server Component as children of Client Component
// app/layout.tsx (Server Component)
import { SidebarProvider } from "@/components/SidebarProvider";
import { Navigation } from "@/components/Navigation";
 
export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <SidebarProvider>
      <Navigation />
      <main>{children}</main>
    </SidebarProvider>
  );
}
tsx
// components/SidebarProvider.tsx
"use client";
 
import { createContext, useState } from "react";
 
export function SidebarProvider({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);
 
  return (
    <SidebarContext.Provider value={{ isOpen, setIsOpen }}>
      {children}
    </SidebarContext.Provider>
  );
}

У другому прикладі SidebarProvider — це Client Component, але children містять Server Components (Navigation та main контент). Це працює, тому що children — це просто проп — вже серіалізований RSC Payload до того, як Client Component побачить його. Client Component не імпортує Server Component; він просто рендерить передані children.

Цей патерн «children» — основний спосіб композиції серверного та клієнтського коду. Це те, як ти загортаєш серверний контент у клієнтський провайдер без того, щоб зробити все клієнтським.

Помилка «зараження клієнтом»#

"use client" створює каскадний ефект. Якщо ComponentA має "use client", і він імпортує ComponentB (який не має директиви), ComponentB також стає клієнтським. Він втягується в клієнтський пакет тому, що імпортується з клієнтського файлу.

Ось як це ламає речі:

tsx
// components/UserProfile.tsx
"use client"; // This is a Client Component
 
import { useState } from "react";
import { UserAvatar } from "./UserAvatar"; // This ALSO becomes a Client Component
 
export function UserProfile({ user }: { user: User }) {
  const [expanded, setExpanded] = useState(false);
 
  return (
    <div>
      <UserAvatar src={user.avatar} /> {/* Client Component now */}
      <button onClick={() => setExpanded(!expanded)}>
        {expanded ? "Less" : "More"}
      </button>
      {expanded && <p>{user.bio}</p>}
    </div>
  );
}

UserAvatar може бути чисто презентаційним — без стану, без ефектів, без інтерактивності. Але оскільки він імпортований із клієнтського файлу, він відправляється в браузер як JavaScript. Більше JavaScript = повільніше початкове завантаження.

Виправлення: винеси UserAvatar з клієнтської межі. Передай його як дочірній елемент або перепиши клієнтський компонент так, щоб він був меншим:

tsx
// app/profile/page.tsx (Server Component)
import { ExpandableSection } from "@/components/ExpandableSection";
import { UserAvatar } from "@/components/UserAvatar";
 
export default async function ProfilePage() {
  const user = await getUser();
 
  return (
    <div>
      <UserAvatar src={user.avatar} /> {/* Stays a Server Component */}
      <ExpandableSection>
        <p>{user.bio}</p>
      </ExpandableSection>
    </div>
  );
}
tsx
// components/ExpandableSection.tsx
"use client";
 
import { useState } from "react";
 
export function ExpandableSection({ children }: { children: React.ReactNode }) {
  const [expanded, setExpanded] = useState(false);
 
  return (
    <div>
      <button onClick={() => setExpanded(!expanded)}>
        {expanded ? "Less" : "More"}
      </button>
      {expanded && children}
    </div>
  );
}

Тепер клієнтська межа мінімальна. UserAvatar залишається серверним. Дочірній контент серверний. Єдиний JavaScript, що відправляється, — це логіка розгортання/згортання.

Я переробив кілька компонентів таким чином і побачив зменшення JavaScript-пакета на 30-40% на деяких сторінках. Не тому, що написав менше коду, а тому, що правильно провів межу.

Патерни отримання даних#

RSC фундаментально змінює підхід до отримання даних. Не «трохи інакше». Фундаментально.

Прямий доступ до бази даних#

Це ломає мозок тим, хто вперше. Ти можеш запитувати базу даних прямо в компоненті:

tsx
// app/posts/page.tsx
import { db } from "@/lib/database";
 
export default async function PostsPage() {
  // This runs on the server. The database query never reaches the client.
  const posts = await db.post.findMany({
    where: { published: true },
    orderBy: { createdAt: "desc" },
    take: 20,
    include: {
      author: {
        select: { name: true, avatar: true },
      },
    },
  });
 
  return (
    <div>
      <h1>Blog Posts</h1>
      {posts.map((post) => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
}

Жодного API-роуту. Жодного fetch-виклику. Жодного стану завантаження. Компонент async, і він await'ить дані перед рендерингом. Результат — HTML, що стрімиться клієнту.

Проте це не означає, що тобі ніколи не потрібні API-роути. Вони все ще необхідні для:

  • Мутацій (Server Actions обробляють більшість випадків, але складні потоки можуть потребувати роутів)
  • Вебхуків від зовнішніх сервісів
  • Публічних API, які споживають сторонні додатки
  • Ендпоінтів, що потрібні як серверному, так і клієнтському коду

Стрімінг із Suspense#

Ось де RSC стає по-справжньому цікавим для продуктивності. Ти можеш стрімити частини сторінки по мірі готовності:

tsx
import { Suspense } from "react";
 
export default function DashboardPage() {
  return (
    <div className="grid grid-cols-3 gap-4">
      {/* This renders immediately */}
      <h1 className="col-span-3 text-3xl font-bold">Dashboard</h1>
 
      {/* Each section streams independently */}
      <Suspense fallback={<CardSkeleton />}>
        <RevenueCard />
      </Suspense>
 
      <Suspense fallback={<CardSkeleton />}>
        <UserStatsCard />
      </Suspense>
 
      <Suspense fallback={<CardSkeleton />}>
        <RecentOrdersCard />
      </Suspense>
    </div>
  );
}
 
// Each of these is an async Server Component
async function RevenueCard() {
  const revenue = await db.analytics.getRevenue(); // Maybe takes 200ms
  return <Card title="Revenue" value={formatCurrency(revenue)} />;
}
 
async function UserStatsCard() {
  const stats = await db.analytics.getUserStats(); // Maybe takes 150ms
  return <Card title="Active Users" value={stats.activeUsers} />;
}
 
async function RecentOrdersCard() {
  const orders = await db.order.findMany({ take: 5 }); // Maybe takes 300ms
  return <OrderList orders={orders} />;
}

Без Suspense сторінка чекатиме найповільнішого запиту (300мс) перед відправленням будь-чого. З Suspense:

  1. Заголовок та скелетони рендеряться негайно
  2. UserStatsCard стрімиться через ~150мс
  3. RevenueCard стрімиться через ~200мс
  4. RecentOrdersCard стрімиться через ~300мс

Користувач бачить прогрес. Найшвидші дані показуються першими. Повна сторінка завантажується за 300мс (як і без стрімінгу), але сприйнятий час завантаження набагато швидший.

Дедуплікація запитів за допомогою React Cache#

Ось проблема: якщо кілька Server Components потребують одних і тих самих даних, ти потенційно робиш дублюючі запити:

tsx
// components/Header.tsx (Server Component)
async function Header() {
  const user = await getUser(); // Query 1
  return <nav>Welcome, {user.name}</nav>;
}
 
// components/Sidebar.tsx (Server Component)
async function Sidebar() {
  const user = await getUser(); // Query 2 — same data!
  return <aside>Role: {user.role}</aside>;
}

React вирішує це через cache():

tsx
// lib/data.ts
import { cache } from "react";
import { db } from "./database";
 
export const getUser = cache(async (userId: string) => {
  return db.user.findUnique({ where: { id: userId } });
});

Тепер getUser з однаковими аргументами під час одного запиту на рендер виконується лише один раз. Другий виклик отримує кешований результат. Це працює через стрімінг-межі Suspense — весь рендер-запит ділить один кеш.

Це не розподілений кеш. Це не Redis. Це кеш на запит, що живе рівно стільки, скільки один рендер-запит. Для глобального кешування тобі все ще потрібні інші рішення.

Кеш Next.js та Revalidation#

Next.js додає свій шар кешування поверх, і чесно — на початку це було для мене найплутанішою частиною. Ось як це працює:

tsx
// Static data — cached indefinitely by default
const posts = await fetch("https://api.example.com/posts");
 
// Revalidate every 60 seconds
const posts = await fetch("https://api.example.com/posts", {
  next: { revalidate: 60 },
});
 
// Never cache (always fresh)
const posts = await fetch("https://api.example.com/posts", {
  cache: "no-store",
});

Для прямих запитів до бази (не fetch), ти контролюєш кешування на рівні сегмента роуту:

tsx
// app/blog/page.tsx
export const revalidate = 3600; // Revalidate this page every hour
export const dynamic = "force-dynamic"; // Never cache
 
export default async function BlogPage() {
  const posts = await db.post.findMany();
  // ...
}

Мій підхід: я починаю з dynamic = "force-dynamic" під час розробки (ніколи не гадай, чи бачиш кешовані дані), а потім додаю кешування по мірі оптимізації. Використовувати кеш як дефолт — це нормально, але тебе підловить, коли ти годину дебагиш «чому мої дані не оновлюються».

Server Actions: Мутації стали простішими#

Server Actions — це мутаційна відповідь RSC. Це функції, що виконуються на сервері, але можуть бути викликані з клієнтських компонентів як звичайні функції.

Основний патерн#

tsx
// app/actions.ts
"use server";
 
import { db } from "@/lib/database";
import { revalidatePath } from "next/cache";
 
export async function createPost(formData: FormData) {
  const title = formData.get("title") as string;
  const content = formData.get("content") as string;
 
  await db.post.create({
    data: { title, content, published: false },
  });
 
  revalidatePath("/posts");
}
tsx
// components/CreatePostForm.tsx
"use client";
 
import { createPost } from "@/app/actions";
 
export function CreatePostForm() {
  return (
    <form action={createPost}>
      <input name="title" type="text" required />
      <textarea name="content" required />
      <button type="submit">Create Post</button>
    </form>
  );
}

createPost виконується на сервері, навіть попри те, що вона використовується в клієнтському компоненті. Next.js автоматично створює HTTP-ендпоінт для неї та обробляє серіалізацію. Клієнт надсилає form data, сервер обробляє її, revalidatePath оновлює кешовані дані, і UI оновлюється.

Це нагадує гладку версію традиційних форм. І мені знадобився тиждень, щоб перестати почувати себе дивно через те, що не пишу окремий API-роут для кожної мутації.

Server Actions з валідацією#

У продакшені завжди валідуй. Server Actions — це публічні ендпоінти. Хтось може їх викликати безпосередньо:

tsx
"use server";
 
import { z } from "zod";
import { db } from "@/lib/database";
import { revalidatePath } from "next/cache";
import { headers } from "next/headers";
 
const createPostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1).max(50000),
});
 
export async function createPost(formData: FormData) {
  // Validate input
  const parsed = createPostSchema.safeParse({
    title: formData.get("title"),
    content: formData.get("content"),
  });
 
  if (!parsed.success) {
    return { error: parsed.error.flatten() };
  }
 
  // Verify authentication
  const headerList = await headers();
  const userId = headerList.get("x-user-id");
  if (!userId) {
    return { error: "Unauthorized" };
  }
 
  // Execute mutation
  const post = await db.post.create({
    data: {
      ...parsed.data,
      authorId: userId,
      published: false,
    },
  });
 
  revalidatePath("/posts");
  return { success: true, postId: post.id };
}

Оптимістичні оновлення#

Server Actions працюють чудово з useOptimistic React 19:

tsx
"use client";
 
import { useOptimistic, useTransition } from "react";
import { toggleLike } from "@/app/actions";
 
export function LikeButton({
  postId,
  initialLiked,
  initialCount,
}: {
  postId: string;
  initialLiked: boolean;
  initialCount: number;
}) {
  const [isPending, startTransition] = useTransition();
  const [optimistic, setOptimistic] = useOptimistic(
    { liked: initialLiked, count: initialCount },
    (state, newLiked: boolean) => ({
      liked: newLiked,
      count: state.count + (newLiked ? 1 : -1),
    })
  );
 
  function handleClick() {
    startTransition(async () => {
      setOptimistic(!optimistic.liked);
      await toggleLike(postId);
    });
  }
 
  return (
    <button onClick={handleClick} disabled={isPending}>
      {optimistic.liked ? "♥" : "♡"} {optimistic.count}
    </button>
  );
}

UI оновлюється негайно (оптимістично), поки серверна екшн виконується у фоні. Якщо серверна екшн не вдається, React відкочує до попереднього стану.

Композиційні патерни#

Ось патерни, до яких я постійно повертаюся в продакшн-коді.

Патерн «Server Data, Client Interaction»#

Найпоширеніший патерн. Сервер отримує дані, клієнт додає інтерактивність:

tsx
// app/products/page.tsx (Server Component)
import { db } from "@/lib/database";
import { ProductGrid } from "@/components/ProductGrid";
 
export default async function ProductsPage() {
  const products = await db.product.findMany({
    where: { active: true },
    include: { category: true },
  });
 
  const categories = await db.category.findMany();
 
  // Pass serializable data to Client Component
  return (
    <ProductGrid
      products={products.map((p) => ({
        id: p.id,
        name: p.name,
        price: p.price,
        category: p.category.name,
        image: p.image,
      }))}
      categories={categories.map((c) => c.name)}
    />
  );
}
tsx
// components/ProductGrid.tsx
"use client";
 
import { useState, useTransition } from "react";
 
interface Product {
  id: string;
  name: string;
  price: number;
  category: string;
  image: string;
}
 
export function ProductGrid({
  products,
  categories,
}: {
  products: Product[];
  categories: string[];
}) {
  const [filter, setFilter] = useState<string>("all");
  const [sort, setSort] = useState<"price" | "name">("name");
 
  const filtered = products
    .filter((p) => filter === "all" || p.category === filter)
    .sort((a, b) =>
      sort === "price" ? a.price - b.price : a.name.localeCompare(b.name)
    );
 
  return (
    <div>
      <div className="flex gap-2 mb-4">
        <select value={filter} onChange={(e) => setFilter(e.target.value)}>
          <option value="all">All Categories</option>
          {categories.map((c) => (
            <option key={c} value={c}>
              {c}
            </option>
          ))}
        </select>
        <select value={sort} onChange={(e) => setSort(e.target.value as "price" | "name")}>
          <option value="name">Sort by Name</option>
          <option value="price">Sort by Price</option>
        </select>
      </div>
      <div className="grid grid-cols-3 gap-4">
        {filtered.map((product) => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </div>
  );
}

Сервер виконує запит до бази. Клієнт обробляє фільтрацію та сортування. Жодних хуків для отримання даних, жодних станів завантаження для початкових даних, жодних API-роутів.

Патерн провайдера#

Для клієнтського стану, що потрібен кільком компонентам (тема, автентифікація, кошик), використовуй провайдер-патерн з серверними дочірніми елементами:

tsx
// app/layout.tsx (Server Component)
import { ThemeProvider } from "@/components/ThemeProvider";
import { CartProvider } from "@/components/CartProvider";
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html>
      <body>
        <ThemeProvider>
          <CartProvider>
            {children} {/* Server Components can be children */}
          </CartProvider>
        </ThemeProvider>
      </body>
    </html>
  );
}

Провайдери — це Client Components, але children (включно зі сторінками та лейаутами) залишаються Server Components. Контекст провайдера доступний будь-якому Client Component в дереві, але Server Components його ніколи не бачать і не потребують.

Паралельне отримання даних#

Один з найулюбленіших патернів для продуктивності:

tsx
// app/dashboard/page.tsx
import { Suspense } from "react";
 
export default function DashboardPage() {
  return (
    <div className="grid grid-cols-2 gap-6">
      <Suspense fallback={<Skeleton />}>
        <UserStats />
      </Suspense>
      <Suspense fallback={<Skeleton />}>
        <RevenueChart />
      </Suspense>
      <Suspense fallback={<Skeleton />}>
        <RecentActivity />
      </Suspense>
      <Suspense fallback={<Skeleton />}>
        <TopProducts />
      </Suspense>
    </div>
  );
}

Кожен компонент отримує дані незалежно та стрімиться по готовності. Це паралельне отримання без Promise.all — React обробляє паралелізм автоматично через Suspense.

Помилки, які я робив#

Дозволь заощадити тобі час, який я витратив.

Помилка 1: Все робити Client Component#

Мій перший рефлекс був додавати "use client" скрізь. Якщо компонент потребував хоча б одного клієнтського хука, весь файл ставав клієнтським. У результаті мій JavaScript-пакет був практично таким самим, як і до RSC.

Виправлення: витягни інтерактивну частину в мінімальний Client Component. Все інше залишай серверним.

Помилка 2: Отримувати дані в Client Components#

Я за звичкою додавав useEffect + useState + fetch в Client Components. Це працює, але це повертає тебе до моделі до RSC. Ти отримуєш водоспади, стани завантаження та лишній JavaScript.

Виправлення: отримуй дані в Server Components. Передавай їх як пропси до Client Components. Якщо тобі потрібні свіжі дані після початкового завантаження (пошук в реальному часі, нескінченний скрол), використовуй Server Actions або SWR/React Query з API-роутом.

Помилка 3: Ігнорувати обмеження серіалізації#

Я передавав Prisma-моделі прямо в Client Components. Вони містили Date-об'єкти (нормально), але також методи (не нормально) та циклічні посилання (зовсім не нормально).

Виправлення: завжди перетворюй серверні дані в прості об'єкти перед передачею:

tsx
// ❌ Don't pass ORM models directly
<ClientComponent user={user} />
 
// ✅ Do map to plain serializable objects
<ClientComponent
  user={{
    id: user.id,
    name: user.name,
    email: user.email,
    createdAt: user.createdAt.toISOString(),
  }}
/>

Помилка 4: Нерозуміння кешування#

Я припустив, що дані є свіжими на кожному запиті. Але Next.js за замовчуванням досить агресивно кешує. Я годину дебагив «чому мій пост не оновлюється», перш ніж усвідомив, що сторінка обслуговується з кешу.

Виправлення: явно вказуй стратегію кешування для кожного роуту під час розробки:

tsx
// Development: always fresh data
export const dynamic = "force-dynamic";
 
// Production: choose appropriate caching
export const revalidate = 3600; // 1 hour

Помилка 5: Забувати про стрімінг-помилки#

Якщо async Server Component кидає помилку під час стрімінгу, UI може застрягти на стані завантаження. Завжди додавай межі помилок:

tsx
import { Suspense } from "react";
import { ErrorBoundary } from "@/components/ErrorBoundary";
 
export default function Page() {
  return (
    <ErrorBoundary fallback={<p>Something went wrong</p>}>
      <Suspense fallback={<Skeleton />}>
        <AsyncComponent />
      </Suspense>
    </ErrorBoundary>
  );
}

Підсумок#

React Server Components — це не просто нова фіча. Це новий спосіб думати про те, де код виконується. Ось що я хочу, щоб ти забрав:

  1. Server Components — це дефолт. Пиши компоненти як серверні. Додавай "use client" лише коли потрібна інтерактивність.
  2. Межа — це архітектура. Де ти розміщуєш "use client" — це найважливіше архітектурне рішення. Тримай її якомога нижче в дереві компонентів.
  3. Дані течуть вниз. Сервер отримує дані. Клієнт додає інтерактивність. Не отримуй дані в клієнтських компонентах, якщо можеш цього уникнути.
  4. Серіалізація — це стіна. Все, що перетинає межу, має бути серіалізовним. Функції, рефи та складні об'єкти не проходять.
  5. Children — це шлях до спасіння. Патерн children дозволяє загортати серверний контент у клієнтські провайдери без того, щоб зробити серверний контент клієнтським.
  6. Server Actions замінюють більшість API-роутів. Для мутацій (CRUD-операцій) Server Actions простіші та типобезпечніші.
  7. Suspense — це не додатково. Використовуй його для кожного async компонента. Це дає стрімінг безкоштовно та покращує сприйняту продуктивність.

RSC — це інвестиція. Перший тиждень незручний. Перший місяць — це дебаг помилок серіалізації та переосмислення меж компонентів. Але після другого місяця ти починаєш писати менше коду, відправляти менше JavaScript та будувати кращий UX, ніж будь-коли раніше.

Ця модель — майбутнє React. Не «одне з можливих майбутніх». Саме майбутнє. Команда React вклала все в цю ставку, і Next.js теж. Час інвестувати в розуміння. Не ідеальне розуміння. Просто достатнє, щоб клієнтська межа клацнула. Решта прийде з практикою.

Схожі записи