Архітектура React Server Components (RSC) є фундаментальним переосмисленням моделі виконання компонентно-орієнтованих користувацьких інтерфейсів. До впровадження RSC у традиційних SPA та SSR архітектурах існував неминучий компроміс: кожен компонент, написаний інженером, неминуче включався до клієнтського JavaScript-бандла. Якщо сторінці було потрібно розпарсити Markdown-документ або відформатувати дату за допомогою розлогого набору локалей, бібліотеки розміром у сотні кілобайтів (marked, date-fns, highlight.js) відправлялися браузеру, сповільнюючи фази завантаження скриптів (Download), парсингу (Parse) та компіляції (Compile) у середовищі V8.
React 19 закріплює специфікацію компонентів із нульовим розміром бандла (Zero-Bundle-Size):
Розподіл компонентів на серверні та клієнтські вирішує чотири фундаментальні архітектурні проблеми фронтенд-інженерії:
pg), криптографії, парсингу Markdown або складної доменної валідації виконуються виключно на бекенді та фізично не надсилаються браузеру.async function ProductPage()) і безпосередньо звертатися до БД через await db.query(), усуваючи необхідність створення та супроводу сотень шаблонних API-ендпоінтів.На відміну від класичного SSR, результатом роботи якого є плаский статичний HTML-рядок, серверний рендерер RSC генерує потік JSONL-подібних інструкцій — React Flight Stream.
Протокол Flight передає семантичну структуру віртуального DOM-дерева з явними точками монтування клієнтських інтерактивних компонентів (Client References).
Ізоляція середовищ забезпечується механізмом Conditional Exports у специфікації package.json середовища Node.js. Під час збирання серверного шару бандлери активують експортну умову "react-server", яка повністю виключає з області видимості браузерні хуки стану (useState, useEffect, useLayoutEffect), перетворюючи їх випадковий імпорт на помилку компіляції.
Браузер ініціює HTTP-запит. Сервер активує середовище react-server та запускає асинхронний рендеринг кореневого RSC.
При виявленні клієнтського компонента сервер записує в потік чанк M з метаданими бандла та вставляє маркер $L.
Чанки J0, J1 та проміси @ відправляються браузеру в міру готовності без очікування повільних запитів.
Браузер зчитує рядки M та ініціює завантаження JS-файлів клієнтських компонентів до завершення всього потоку.
Клієнтський рушій десеріалізує Flight-граф і безшовно монтує його в активне дерево Fiber.
У чому полягає фундаментальна відмінність даних, які передаються за протоколом Flight, від традиційної відповіді класичного SSR?
Парсинг складних формул, математична обробка масивів даних і генерація графів відбуваються на сервері. Бібліотеки розміром у десятки мегабайтів повністю виключаються з клієнтського JavaScript-бандла.
Перехід на наступний маршрут запитує у сервера лише Flight-потік нового екрана. Клієнтський React порівнює нове серверне дерево зі старим і оновлює виключно змінені вузли DOM, не скидаючи відтворення медіа, стан форм або активний фокус.
Серверний компонент може передати нерозв'язаний проміс безпосередньо через пропси в клієнтський компонент. Клієнт розпаковує проміс за допомогою хука use(promise) всередині межі <Suspense>, миттєво відображаючи скелетон до надходження даних.
Клієнтський компонент модального вікна або табів керує інтерактивним відкриттям і закриттям, а важкий контент усередині передається як серверний слот children без витоку бібліотек у бандл.
Виклик асинхронних серверних функцій. Прямі запити до БД. Побудова віртуального дерева з маркерами Client Reference.
Генерація текстового потоку: серіалізація JSX-вузлів у префікси J, модулів компонентів у рядки M, промісів у дескриптори @.
Передавання чанків через HTTP з Transfer-Encoding: chunked. Браузер розпочинає обробку до завершення генерації всієї сторінки.
Збирання віртуального дерева на клієнті. Розв'язання посилань $L через вебпак/турбопак маніфест. Монтування в дерево Fiber.
Протокол Flight передає дані рядками, розділеними символом нового рядка \n. Кожен рядок починається з ідентифікатора та типу запису:
| Префікс / Символ | Призначення | Приклад рядка протоколу |
| :--- | :--- | :--- |
| M (Module) | Client Reference: шлях до файлу, ім'я експорту та список JS-чанків | M1:{"id":"./components/Cart.tsx","name":"default","chunks":["client1"]} |
| J (JSON Tree) | Серіалізоване віртуальне дерево JSX-вузлів і пропсів | J0:["$","div",null,{"className":"container","children":"$@2"}] |
| $ (React Element) | Маркер віртуального JSX елемента (аналог Symbol.for('react.transitional.element')) | ["$","span",null,{"children":"Hello"}] |
| $L (Lazy Client) | Підстановка клієнтського компонента за ID із запису M | ["$","$L1",null,{"count":0}] |
| $S (Suspense) | Межа очікування Suspense | "$Sreact.suspense" |
| @ (Promise) | Посилання на відкладене значення (Thenable) | J0:{"analytics":"$@3"} |
| P (Promise Resolve) | Розв'язання проміса з переданим значенням | 3:{"views":1240,"conversions":42} |
Коли компілятор зустрічає директиву 'use client', він підміняє вихідний модуль на так званий Client Reference Proxy.
Вихідний клієнтський компонент:
'use client';
import React, { useState } from 'react';
export default function InteractiveCounter({ step = 1 }: { step?: number }) {
const [count, setCount] = useState(0);
return (
<button
onClick={() => setCount(c => c + step)}
className="px-3 py-1 bg-accent-violet text-white font-mono rounded cursor-pointer"
>
Кліки: {count}
</button>
);
}Серверний компонент імпортує його, однак збирач (Turbopack або Webpack) на сервері замінює тіло компонента на порожній посилальний об'єкт:
// Згенерований збирачем проксі-об'єкт у середовищі react-server
export default {
$$typeof: Symbol.for('react.client.reference'),
$$id: './src/components/InteractiveCounter.tsx#default',
$$async: false,
};Коли серверний рендерер зустрічає елемент із типом Symbol.for('react.client.reference'), він не виконує функцію компонента. Натомість він емітить у потік рядок маніфесту M, що містить URL чанка для браузера, і залишає маркер $L у JSON-структурі дерева.
Прямо імпортувати Server Component у Client Component заборонено (збирач автоматично перетворить імпортований файл на клієнтський). Канонічний шлях — передавання через пропси (Slot Pattern):
// 1. Client Component: інтерактивна обгортка-акордеон
'use client';
import React, { useState } from 'react';
interface AccordionProps {
title: string;
children: React.ReactNode; // Серверний слот
}
export function Accordion({ title, children }: AccordionProps) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="border border-border rounded-lg overflow-hidden my-4">
<button
onClick={() => setIsOpen(prev => !prev)}
className="w-full text-left p-4 bg-bg-elevated font-semibold flex justify-between items-center cursor-pointer"
>
<span>{title}</span>
<span>{isOpen ? '▲' : '▼'}</span>
</button>
{isOpen && <div className="p-4 bg-bg-surface">{children}</div>}
</div>
);
}
// 2. Server Component: важкий контент, який збирається в середовищі react-server
import { readFile } from 'node:fs/promises';
async function HeavyServerReport({ filename }: { filename: string }) {
// Прямий доступ до файлової системи на сервері
const rawData = await readFile(`./reports/${filename}`, 'utf-8');
return <div className="prose font-mono text-sm">{rawData}</div>;
}
// 3. Компонування на сервері: Donut Pattern
export async function ReportPage() {
return (
<main className="p-8">
<h1 className="text-2xl font-bold mb-4">Фінансовий звіт</h1>
{/*
Accordion виконується на клієнті, але HeavyServerReport
рендериться на сервері й передається як готовий Flight-слот children!
*/}
<Accordion title="Показати детальний аудит">
<HeavyServerReport filename="q3-audit.log" />
</Accordion>
</main>
);
}Чому спроба передати звичайну стрілкову функцію onClick={() => alert('ok')} із Server Component у Client Component завершується фатальною помилкою?
Для налагодження мережевих відповідей та валідації передаваних у Client Components даних розробимо утиліту перехоплення та інспекції Flight-чанків:
interface DecodedFlightRecord {
type: 'CLIENT_MODULE' | 'JSX_TREE' | 'PROMISE_RESOLVE' | 'UNKNOWN';
id: string;
rawPayload: string;
sizeBytes: number;
}
export async function inspectFlightStream(
stream: ReadableStream<Uint8Array>
): Promise<DecodedFlightRecord[]> {
const reader = stream.getReader();
const decoder = new TextDecoder();
const records: DecodedFlightRecord[] = [];
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunkText = decoder.decode(value, { stream: true });
const lines = chunkText.split('\n').filter(l => l.trim().length > 0);
for (const line of lines) {
const colonPos = line.indexOf(':');
if (colonPos === -1) continue;
const tag = line.slice(0, colonPos);
const payload = line.slice(colonPos + 1);
let type: DecodedFlightRecord['type'] = 'UNKNOWN';
if (tag.startsWith('M')) type = 'CLIENT_MODULE';
else if (tag.startsWith('J')) type = 'JSX_TREE';
else if (!isNaN(Number(tag))) type = 'PROMISE_RESOLVE';
records.push({
type,
id: tag,
rawPayload: payload,
sizeBytes: new TextEncoder().encode(line).length,
});
}
}
} finally {
reader.releaseLock();
}
return records;
}Оскільки межа між RSC та Client Component проходить через мережу за протоколом Flight, усі пропси повинні бути серіалізованими. Спроба передати функції-обробники (onClick={() => {}}), екземпляри класів із методами або символи Symbol призведе до фатальної помилки серіалізатора Flight.
// ❌ ПОМИЛКА: передавання анонімного замикання з RSC у клієнтський компонент
export async function ServerPage() {
const handleAction = () => {
console.log('Клік');
};
return <ClientButton onClick={handleAction} />; // Crash: Functions cannot be passed directly to Client Components
}
// ✅ ПРАВИЛЬНО: передавання Server Action із директивою 'use server' або примітивів
export async function ServerPage() {
async function serverAction() {
'use server';
// Безпечне виконання на сервері через RPC
}
return <ClientButton action={serverAction} />;
}Якщо модуль, що містить приватні ключі API або звернення до бази даних, випадково імпортується ланцюжком клієнтських компонентів без захисного пакета server-only, збирач або аварійно включить секрети в публічний JS-бандл, або впаде з помилкою відсутності Node.js API в браузері.
// ❌ НЕБЕЗПЕЧНО: модуль не захищено, може бути випадково імпортовано на клієнті
import { db } from '@/lib/db';
export async function getPrivateUserData(userId: string) {
return db.user.findUnique({ where: { id: userId } });
}
// ✅ ПРАВИЛЬНО: використання poison-модуля server-only
import 'server-only'; // Викличе помилку збирання при спробі клієнтського імпорту
import { db } from '@/lib/db';
export async function getPrivateUserData(userId: string) {
return db.user.findUnique({ where: { id: userId } });
}Поширена омана: вважати, що RSC замінює SSR. Насправді вони доповнюють один одного. RSC формує Flight-граф (структуру компонентів). Класичний SSR бере цей Flight-граф на сервері й транслює його в первинний HTML для миттєвого відображення краулерами та користувачами. Нерозуміння цієї різниці призводить до спроб читати HTML-парсером потік протоколу Flight.
Компоненти за замовчуванням у React 19 компілюються в середовищі react-server. Спроба викликати useState, useEffect, useReducer або useRef у такому компоненті призведе до помилки компілятора, оскільки пакет react у середовищі react-server не експортує зазначені хуки.