Головна / Статті / React Server Components: Архітектура безпакетного відображення контенту

React Server Components: Архітектура безпакетного відображення контенту

Зрозуміти логіку створення React Server Components — від розміру бандлу та проблем типу «водоспаду» до межі між сервером та клієнтом та вмісту пакетів RSC.

3487 слів

Якщо ви витратили час, намагаючись зрозуміти React Server Components, і залишилися ще більш розгубленими, ніж на початку, це не ознака того, що ви пропускаєте щось очевидне. Це ознака того, що пояснення, які ви знайшли, були частиною проблеми. Протягом кількох років офіційний підхід описував Server Components як „компоненти, які відображаються на сервері“, що дуже схоже на те, що вже робили getServerSideProps чи класичне серверне відображення у Next.js. Потім з’явилася твердження, що ці компоненти „не надсилають жодного JavaScript на клієнт“, що звучить скоріше як трюк для покращення продуктивності, ніж як новий спосіб структурування додатку. Потім з’явився App Router, і раптово кожен файл у проекті Next.js став Server Component за замовчуванням, якщо тільки ви не додасте спеціальний рядок на початку файлу.

Ця плутанина не була вашою помилкою. Вона виникла тому, що справді нову парадигму описували за допомогою лексики, запозиченої зі старої. Цей посібник має на мету подолати цю прогалину. До кінця читання ви повинні зрозуміти не лише механізми Server Components, а й причини, чому вони є зовсім іншим підходом до розробки React-додатків.

Проблема, яку насправді вирішують RSC

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

Пастка розміру пакета

In the classic React model, every component you author turns into JavaScript that gets shipped to the browser, no exceptions. It makes no difference whether that component just renders some static markdown, does a simple date calculation, or hits a database. As long as it lives somewhere in your component tree, its code lives in your bundle too.

That creates an unforgiving trade-off. Adding more components inflates your JavaScript payload. A heavier payload pushes out your Time to Interactive. In practice, you end up delivering code to browsers that never had any reason to execute it.

The Waterfall Problem

До появи Server Components будь-який компонент, якому потрібні були дані, змушений був їх отримувати після того, як вони надійшли на клієнта. Типовим підходом було відображення тимчасової оболонки, запуск функції useEffect, очікування відповіді, а лише потім — відображення справжнього контенту. Якщо цей контент містив вкладений компонент із власними потребами у дані, цикл повторювався: ще одна функція, ще одне очікування, ще одна затримка, яка додавалася до попередньої. Ця ланцюгова реакція і є процесом отримання даних, і саме вона пояснює, чому багато додатків на React здаються повільними, навіть коли їхні бекенд-API відповідають швидко.

Одним із способів вирішення проблеми було переміщення процесу отримання даних на рівень маршруту за допомогою інструментів на кшталт getServerSideProps чи загрузчиків маршрутів, але це мало свою ціну: це порушило принцип самодостатності компонентів. Раптово сторінка мусила знати про дані, які концептуально належали її дочірнім компонентам, і компоненти перестали бути незалежними одиницями.

Плата за гідратацію

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

React Server Components вирішують усі три ці проблеми на рівні архітектури, а не як поступове покращення продуктивності.

Ментальна модель: Server Components — це не „SSR 2.0“

Ось основна ідея, на якій ґрунтується весь цей посібник: Server Components — це не спосіб відображення сторінок. Це окрема категорія компонентів.

Класичний React визнавав лише один тип компонентів. Він виконувався у браузері. Він міг зберігати стан, виконувати ефекти та реагувати на події. Усі етапи його життєвого циклу, від створення до знищення, відбувалися на клієнтській стороні.

React Server Components додають ще одну категорію. Серверний компонент виконується на сервері. Він може без обмежень працювати з файловою системою, безпосередньо запитувати базу даних чи використовувати бібліотеки, які працюють лише в Node.js. Однак він не може використовувати useState, useEffect чи приєднувати обробники подій, оскільки взагалі не виконується в браузері. Для нього не існує кроку гідратації. Його код навіть не передається клієнту у вигляді JavaScript.

Корисний спосіб це уявити: дерево компонентів React тепер є складним. Деякі гілки генеруються на сервері, інші — у браузері. Гілки з боку сервера відображаються лише один раз під час запиту, а результат їхньої роботи передається клієнту у вигляді серіалізованих елементів React. Гілки з боку клієнта відображаються у браузері як зазвичай та зберігають усі інтерактивні функції, до яких ви звикли.

Це інший механізм, ніж SSR. Server-side rendering генерує всю програму на сервері у форматі HTML, а потім знову завантажує її у браузері. На відміну від цього, RSC генерує лише певні компоненти на сервері та зовсім не передає їхній базовий код у браузер.

Що насправді надсилається клієнту

Компонент сервера під час відображення не генерує HTML. Натомість він створює серіалізований опис свого виводу, який називається RSC Payload — потік інструкцій у форматі JSON, які повідомляють React у клієнтській частині, як скласти дерево елементів, необхідне для відображення.

Ось послідовність подій, які відбуваються на тлі, коли запитується сторінка з компонентами сервера:

  1. React генерує компоненти сервера на сервері.
  • Для кожного серверського компонента він передає фактичний отриманий результат — створені елементи, а не вихідний код, з якого вони були створені.
  • Для кожного клієнтського компонента він передає посилання замість цього: маркер, який вказує, де слід розмістити цей компонент, разом із необхідними параметрами.
  • Сервер передає весь цей набір даних до браузера у потоці.
  • Рунтайм React у браузері аналізує цей набір даних та створює відповідну ієрархію компонентів.
  • Клієнтські компоненти потім завантажують свій код та проходять процедуру гідратації. Серверські компоненти, які вже були отримані у форматі HTML, зовсім не потребують цього кроку.
  • Ключовий висновок полягає у тому, що код серверних компонентів ніколи не потрапляє до пакету JavaScript, який надсилається браузерам. Уявіть собі компонент MarkdownRenderer, створений на основі бібліотеки для обробки Markdown розміром 200 КБ. Якщо цей компонент є серверним, вся бібліотека розміром 200 КБ залишається на сервері. Браузер бачить лише структуру, яку створив парсер, але ніколи не бачить самого парсера.

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

    Межа між сервером та клієнтом

    Усередині App Router кожен файл вважається Server Componentом, якщо не вказано інше. Це не просто стилістичний стандарт — це структурна припущення, вбудоване у фреймворк, яке відображає ідею про те, що більша частина вашого інтерфейсу зовсім не потребує виконання у браузері.

    Натомість Client Component — це код, призначений для виконання на комп’ютері користувача. Щоб позначити файл як такий, потрібно розмістити на початку директиву "use client". Це не просто рекомендація — це жорсткий обмежувальний елемент. Він інструктує компілятор React про те, що все, що визначено у цьому файлі, разом із всім, що він завантажує через імпорти, має бути запаковано та надіслано до браузера.

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

    На перший погляд це здається неправильним. Хіба код серверної частини не має бути доступним з будь-якого місця, включаючи файли клієнта? Насправді ні, якщо розглянути, як насправді передаються дані:

    • Спочатку виконується компонент серверу, який має прямий доступ до вашої бази даних та ресурсів серверної частини. Він отримує необхідні дані та створює макет. У цьому макеті він може відобразити щось на кшталт <UserProfile />, що потребує інтерактивності — тому ця частина пишеться як компонент клієнта. Батьківський компонент серверу передає отримані дані користувача у вигляді параметрів.
  • Клієнтський компонент просто використовує ці параметри та відображає інтерактивні частини. У нього немає можливості звернутися назад та імпортувати серверський компонент, адже до моменту його виконання у браузері виконання на сервері вже давно завершилося. Не залишається жодного серверського процесу, який можна було б викликати.
  • Ось чому така схема не може працювати:

    // ❌ Impossible: Client Component importing Server Component
    'use client';
    import { ServerDataFetcher } from './ServerDataFetcher'; // This breaks!
    export function ClientWidget() {
      return (
        <div>
          <ServerDataFetcher /> {/* Cannot render a server component here */}
        </div>
      );
    }
    

    Але структурування у зворотному порядку є цілком допустимим:

    // ✅ Correct: Server Component importing Client Component
    // This is a Server Component (no 'use client')
    import { ClientWidget } from './ClientWidget';
    async function ServerDataFetcher() {
      const data = await db.query('SELECT * FROM posts');
    
      return (
        <div>
          <h1>Latest Posts</h1>
          {data.map(post => (
            <ClientWidget key={post.id} post={post} />
          ))}
        </div>
      );
    }
    

    Патерн є простим: серверський компонент відповідає за отримання даних та структурне відображення, а потім передає дані до інтерактивних компонентів, розташованих на кінцях „дерева“. Ці кінцеві компоненти обробляють кліки, надсилання форм та анімації. Результатом є архітектура, яка по суті є деревом, відображеним на сервері, з ділянками інтерактивності, розташованими на його краях.

    Асинхронні компоненти: революція у отриманні даних

    Класичний React ніколи не дозволяв функціям компонентів бути асинхронними. Використання await безпосередньо всередині тіла компонента не було можливим — доводилося вручну працювати з useEffect та керувати станом завантаження.

    Server Components скасовують цю обмеження: вони можуть бути async. Це здається незначним синтаксичним доповненням, але воно суттєво змінює основну архітектуру.

    // ✅ Server Component: Direct data access, no useEffect
    async function BlogPostList() {
      // This runs on the server. No fetch call in the browser.
      const posts = await db.post.findMany({
        orderBy: { createdAt: 'desc' },
        take: 10
      });
    
      return (
        <ul>
          {posts.map(post => (
            <li key={post.id}>
              <h2>{post.title}</h2>
              <p>{post.excerpt}</p>
            </li>
          ))}
        </ul>
      );
    }
    

    Зверніть увагу на все, чого тут немає. Немає useState для відстеження статусу завантаження, немає useEffect для виклику операції fetch, немає вручну створеного екрана-скелета. Компонент просто перетворює записи бази даних безпосередньо на елементи React. Отримання даних тепер відбувається для кожного окремого компонента, а не для кожної маршрутизації, і воно відбувається повністю на сервері, ніколи в браузері.

    Через це проблема водоспаду зникає — сервер може обробити кожен асинхронний компонент одночасно, перш ніж надіслати відповідь клієнту. Виклики бази даних виконуються у тому ж центрі обробки даних, що й ваш бекенд, тому затримка вимірюється у мікросекундах, а не у мілісекундах, як це типово для подвійних передач даних через мобільне з’єднання.

    Директива “use client”: коли та чому

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

    • Локальний стан через useState або useReducer
    • Хуки життєвого циклу, такі як useEffect або useLayoutEffect
    • Обробка подій, наприклад onClick або onSubmit
  • Прямі API браузера — localStorage, window, document
  • Хуки, пов’язані з контекстом браузера, включаючи певні варіанти використання useRouter чи такі засоби, як useMediaQuery
  • Поширеною помилкою є розміщення цієї директиви занадто вгорі у дереві компонентів. Розробники часто перетворюють цілу сторінку на клієнтський компонент лише тому, що одна вбудована кнопка потребує інтерактивності.

    // ❌ Bad: Making the whole page client-side for one interactive element
    'use client';
    import { useState } from 'react';
    import { HeroSection } from './HeroSection'; // Static, could be server
    import { LikeButton } from './LikeButton';   // Interactive, needs client
    export default function Page() {
      return (
        <div>
          <HeroSection />
          <LikeButton />
        </div>
      );
    }
    

    У цьому фрагменті HeroSection є суто статичною маркувальною структурою, яка могла б легко залишатися обробленою на сервері. Але оскільки "use client" було оголошено на рівні батьківського компонента, кожен імпорт під ним — включаючи цю статичну секцію — все одно пакується та надсилається до браузера.

    Рішення: перемістити "use client" нижче в дерево

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

    // ✅ Good: Page is server, only LikeButton is client
    // page.tsx (Server Component by default)
    import { HeroSection } from './HeroSection';
    import { LikeButton } from './LikeButton';
    export default function Page() {
      return (
        <div>
          <HeroSection />
          <LikeButton postId="123" />
        </div>
      );
    }
    
    // LikeButton.tsx
    'use client';
    import { useState } from 'react';
    export function LikeButton({ postId }) {
      const [liked, setLiked] = useState(false);
    
      return (
        <button onClick={() => setLiked(!liked)}>
          {liked ? '❤️' : '🤍'}
        </button>
      );
    }
    

    За такою структурою ні HeroSection, ні Page ніколи не надсилаються до браузера у вигляді JavaScript. Лише LikeButton, разом із викликом useState, потрапляє туди. Це і є практичним механізмом досягнення мети нульового розміру пакета: якомога більше елементів дерева компонентів залишається на сервері, а клієнтська частина призначена лише для конкретних вузлів, яким справді потрібна інтерактивність.

    Переплетення: паттерн, який робить RSC потужним

    Техніка, яка надає RSC справжньої сили, — це переплетення: розміщення серверних компонентів всередині клієнтських компонентів, передача даних, отриманих на сервері, у вигляді параметрів, та дозвіл цим клієнтським компонентам використовувати слоти children, які можуть містити ще більше серверних компонентів.

    // Layout.tsx (Server Component)
    import { Sidebar } from './Sidebar';
    import { AnalyticsProvider } from './AnalyticsProvider';
    export default async function DashboardLayout({ children }) {
      // Fetch user data on the server
      const user = await getCurrentUser();
      const permissions = await getUserPermissions(user.id);
    
      return (
        <div className="dashboard">
          <Sidebar user={user} permissions={permissions} />
    
          {/* AnalyticsProvider is a Client Component */}
          <AnalyticsProvider userId={user.id}>
            {/* children here can be a Server Component page */}
            <main>{children}</main>
          </AnalyticsProvider>
        </div>
      );
    }
    
    // AnalyticsProvider.tsx
    'use client';
    import { createContext, useContext } from 'react';
    const AnalyticsContext = createContext(null);
    export function AnalyticsProvider({ userId, children }) {
      // Client-side analytics initialization
      useEffect(() => {
        analytics.identify(userId);
      }, [userId]);
    
      return (
        <AnalyticsContext.Provider value={{ userId }}>
          {children}
        </AnalyticsContext.Provider>
      );
    }
    

    Тут DashboardLayout працює на сервері та безпосередньо запитує базу даних. Він передає ці дані до Sidebar, який сам може бути серверним або клієнтським компонентом залежно від своїх потреб. Крім того, решту сторінки обгортає AnalyticsProvider, клієнтський компонент, необхідний у браузері для запуску сторонньої бібліотеки аналітики.

    Важливою деталлю є властивість children. Те, що відображається всередині AnalyticsProvider, не обов’язково має стати клієнтським кодом лише через те, що воно знаходиться там. React передає вже оброблений вихід серверського компонента через клієнтський компонент у вигляді children — клієнтський компонент просто виступає у ролі обгортки чи межі, а не перетворювача, який змушує все, що знаходиться всередині нього, виконуватися на клієнтській стороні.

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

    Навантаження RSC: погляд під капот

    Обробка серверського компонента не створює HTML безпосередньо. Натомість React випромінює навантаження RSC — бінарний потік, який концептуально схожий на це:

    1:I["node_modules/react/jsx-runtime.js", "jsx"]
    2:I["./components/ClientWidget.js", "default"]
    0:["quot;, "div", null, {"children": [
      ["quot;, "h1", null, {"children": "Latest Posts"}],
      ["quot;, "@2", null, {"postId": "123", "title": "Hello World"}]
    ]}]
    

    Кожен рядок у цьому потоці є інструкцією. Символ $ позначає елемент React. I означає імпорт модуля клієнтського компонента. Посилання на кшталт @2 вказує на другий імпорт — у цьому випадку ClientWidget. Зауважте, що сервер вже повністю відрендерував тег h1, але щодо ClientWidget він лише передав йому пропси та вказав місце розташування його коду, не відрендеровуючи його самого.

    Як тільки браузер отримує цей потік, він обробляє посилання на імпорти, завантажує необхідні пакети клієнтських компонентів та формує кінцеву структуру компонентів. Оскільки дані надходять поступово, браузеру не потрібно чекати на завершення перед початком відображення. Саме ця поступова доставка дозволяє технології RSC підтримувати стрімове серверне відображення, уникаючи при цьому звичайних обмежень, пов’язаних із процесом гідратації.

    Кешування та перевірка даних

    Next.js 15 та новіші версії надають серверним компонентам доступ до всього спектру стратегій кешування:

    1. Статичне відображення (стандартний режим). Якщо компонент не читає динамічні дані та не виконує запит до некешованих джерел, він статично генерується під час будування проекту.
    // Cached indefinitely at build time
    async function ProductList() {
      const products = await fetch('https://api.example.com/products');
      // ...
    }
    
    1. Динамічне відображення. Виклик функцій cookies(), headers() чи ознак searchParams, а також явне встановлення значення export const dynamic = 'force-dynamic' змушують компонент відображатися при кожному запиті.
    2. Поновлення даних за таймером.
    // Revalidate every 60 seconds
    async function ProductList() {
      const products = await fetch('https://api.example.com/products', {
        next: { revalidate: 60 }
      });
      // ...
    }
    
    1. Поновлення даних за запитом.
    // app/api/revalidate/route.ts
    import { revalidatePath } from 'next/cache';
    export async function POST() {
      revalidatePath('/products');
      return Response.json({ revalidated: true });
    }
    

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

    Постійні непорозуміння

    „Компоненти сервера існують переважно для SEO.“ Це не зовсім так — краща індексація пошуку є побічним ефектом, а не метою. Справжня мотивація — скоротити кількість JavaScript на стороні клієнта та дозволити отримання даних на рівні компонентів.

    „Будь-який компонент, який використовує хук, потребує use client.“ Це правда лише тоді, коли хук дійсно залежить від API браузера. Хук use у React 19 може розпакувати обіцянки та безпосередньо читати контекст усередині компонентів сервера, тож багато хуків працюють без проблем, навіть не торкаючись клієнта.

    „Компоненти сервера роблять марними API-шляхи.“ Це не так — вони працюють разом. Компоненти сервера беруть на себе завдання отримання даних під час початкового відображення, але вам все одно потрібні API-шляхи для обробки змін, вхідних webhook-ів від сторонніх сервісів та будь-якого отримання даних на стороні клієнта після гідратації.

    «Компоненти серверу не мають стану». У них відсутній стан у стилі браузера — немає доступного useState — але вони можуть вільно отримувати дані з джерел стану на серверній стороні, таких як бази даних, кеші, файлова система та змінні середовища.

    «Контекст заборонений у компонентах серверу». Ви справді не можете використовувати React Context всередині компонента серверу, оскільки контекст існує саме для уникнення передачі параметрів по шляху «дерево → листок» у компонентах, які рендеруються на клієнті. Замість цього можна передавати дані як звичайні параметри, і оскільки серверний рендеринг синхронно обробляє дерево компонентів зверху вниз, Next.js також пропонує опції на кшталт unstable_rootParams разом із звичайною передачею параметрів.

    Ситуація на 2026 рік

    Тепер, коли React 19 досяг стабільності, супутні інструменти значно дорослішали:

    • Функції сервера дозволяють компонентам клієнта викликати асинхронну функцію, яка виконується безпосередньо на сервері, тим самим пом’якшуючи межу між клієнтом та сервером під час змін даних.
    • Хук use надає компонентам клієнта можливість розгортати обіцянки та контекст без необхідності використання useEffect.
    • Часткова попередня обробка (PPR) у Next.js 15 дозволяє подавати статичну основу безпосередньо з CDN, тоді як динамічні частини завантажуються з основного сервера.
    • Компілятор React автоматично керує мемоізацією компонентів клієнта, скорочуючи кількість ручних викликів useMemo та роблячи перехід між клієнтом та сервером більш плавним.

    Концептуальна модель на цьому етапі стабілізувалася. Компоненти сервера більше не є функцією, яку можна увімкнути додатково — вони є основною передумовою побудови сучасних додатків на React. Тепер компоненти клієнта є винятком: це свідомо створений механізм для інтерактивного шару.

    Зміна архітектури, а не лише продуктивності

    Компоненти сервера React — це не просто оптимізація продуктивності. Вони означають переосмислення того, де насправді виконується код React. Протягом приблизно десяти років React була по суті браузерною бібліотекою, яку примусово використовували на серверній стороні переважно для задоволення вимог SEO. Сьогодні це щось інше: повноцінна система компонентів, яка працює на обох кінцях мережевого з’єднання, причому кожен з цих елементів має чітко визначені межі, обов’язки та характеристики продуктивності.

    Сервер більше не є просто місцем для отримання даних — тепер це справжнє середовище відображення. А браузер більше не є єдиним місцем для компонентів React; натомість саме тут знаходиться інтерактивність.

    Як тільки це розуміння сформується, багато непорозумінь щодо RSC зникає. Питання змінюється з «Чи потрібен мені тут use client?» на «Де має знаходитися цей конкретний фрагмент логіки?» Саме це питання варто ставити, і саме такий підхід допомагає розвиватися за мірою зростання додатків.

    Пов’язана література

  • Відмова від CSS-in-JS у часі виконання: серверні компоненти та стилі на етапі збірки — чому CSS-in-JS у часі виконання суперечить серверним компонентам React та цілям щодо продуктивності, як порівнюються Tailwind, CSS Modules та бібліотеки без використання ресурсів у часі виконання, та як безпечно здійснити міграцію.