open atlas
↑ К треку
Архитектура фронтенда FE · 02 · 03

React Server Components и Suspense streaming

Как RSC async компоненты устраняют клиентский JS для статических данных, и как Suspense boundaries позволяют серверу стримить шелл за 100ms, пока медленные секции приходят позже.

FE Middle ◷ 12 min
Уровень
ОсновыJuniorMiddleSenior

Next.js 15 приложение с RSC может показать шелл лэйаута пользователю за 100ms, даже когда данные за ним занимают 800ms для fetch’а — без выполнения какого-либо JavaScript в браузере для этого контента. Как?

Что такое React Server Components

После этого урока ты поймёшь, почему добавление 'use client' к каждому компоненту уничтожает преимущество RSC — и как провести границу правильно.

React Server Components (RSC) — это React компоненты, которые выполняются исключительно на сервере. Они являются async функциями, которые могут обращаться к базам данных, читать файлы или вызывать любой server-side API. Их вывод — HTML плюс специальное сериализованное React-дерево (RSC Payload) — но никогда не JavaScript, отправляемый в браузер.

Ключевое разделение:

  • Server Components — рендерятся на сервере, производят HTML. Без useState, без useEffect, без браузерных API. Ноль KB кода компонента в браузерном bundle.
  • Client Components — помечаются 'use client' вверху файла. Запускаются и на сервере (для начального HTML) и на клиенте (для интерактивности). Их код компонента отправляется в браузер.
// ServerCard.tsx — Server Component (директива не нужна)
// Запускается только на сервере. JS в браузер не отправляется.
async function ServerCard({ id }: { id: string }) {
  const data = await db.product.findUnique({ where: { id } });
  return <div>{data.name}</div>;
}

// AddToCart.tsx — Client Component
'use client';
import { useState } from 'react';
export function AddToCart({ productId }: { productId: string }) {
  const [added, setAdded] = useState(false);
  return <button onClick={() => setAdded(true)}>{added ? 'Добавлено' : 'В корзину'}</button>;
}

На типичной странице товара, заголовок, описание и изображение — Server Components: они fetch’ят один раз при запросе, отдают HTML, не отправляют JS. Только интерактивные листья (AddToCart, фильтры, модалки) становятся Client Components.

Как Suspense включает стриминг

React Suspense — примитив для “покажи fallback пока эта часть дерева загружается”. На сервере Suspense boundary позволяет остальному дереву стримиться, пока suspended компонент ещё разрешает своё async обещание.

Lifecycle запроса со стримингом:

  1. Запрос приходит на сервер
  2. Сервер начинает рендеринг. Шелл лэйаута (nav, заголовок страницы, skeleton-shaped fallbacks) не нуждается в async данных — рендерится мгновенно
  3. Чанк HTML шелла отправляется по открытому response stream. TTFB срабатывает здесь (~50–100ms)
  4. Сервер запускает async data fetch’и внутри Suspense boundaries, параллельно
  5. По мере разрешения каждого fetch’а, сервер рендерит эту секцию и стримит HTML чанк
  6. Браузер получает чанки, заменяет Suspense fallbacks на реальный контент
  7. LCP срабатывает когда самый большой визуально элемент заcтримился

Вместе эти семь шагов означают, что пользователь видит живой интерактивный шелл уже через 100ms, а не пустой экран. Без шага 3 (ранняя отправка шелла) вся цепочка блокируется на самом медленном fetch’е шага 4.

RSC streaming timeline
T=0: запрос приходит, шелл рендерится мгновенно
T=80ms: HTML шелла стримится → TTFB
T=300ms: быстрая секция готова, стримится
T=600ms: медленная секция готова → LCP

Польза: без стриминга TTFB был бы равен самому медленному fetch’у (600ms). Со стримингом TTFB равен времени рендеринга шелла (80ms). Пользователь видит контент намного раньше.

Правило границы ‘use client’

Когда подключаешь новый компонент, сопротивляйся рефлексу добавить 'use client' сверху — сначала проверь, нужно ли ему вообще какое-то состояние браузера.

Лучшая архитектура: держать как можно больше дерева как Server Components, переносить 'use client' к листьям, которым реально нужна интерактивность.

// Page.tsx — Server Component (по умолчанию)
async function ProductPage({ id }) {
  const product = await fetchProduct(id);  // server-side, нуль клиентского JS
  return (
    <main>
      <h1>{product.name}</h1>
      <ProductImages urls={product.images} />     {/* Server Component */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <ReviewsSection productId={id} />          {/* Server Component, стримится */}
      </Suspense>
      <AddToCart productId={id} />                 {/* Client Component */}
    </main>
  );
}

Влияние на размер bundle’а реально: Next.js 15 приложение, которое переносит большинство компонентов на server-side, может уменьшить клиентский bundle с 240KB до 60KB.

Ловушка dehydration/rehydration

Распространённый баг при добавлении TanStack Query в RSC приложение: сервер fetch’ит данные, рендерит HTML, отправляет. Клиент hydrates, монтирует те же query хуки и fetch’ит те же данные снова — два round-trip’а для одной загрузки страницы.

Фикс: dehydrate серверный query cache в HTML, rehydrate на клиенте:

// На сервере
const queryClient = new QueryClient();
await queryClient.prefetchQuery({ queryKey: ['product', id], queryFn: () => fetchProduct(id) });
const dehydratedState = dehydrate(queryClient);

// В HTML — передать клиенту
<HydrationBoundary state={dehydratedState}>
  <ProductPage />
</HydrationBoundary>

Теперь useQuery(['product', id]) на клиенте находит данные в кэше мгновенно — без повторного fetch’а.

Почему это работает

Утверждение RSC о “нуле JS для серверных компонентов” значимо только если ты реально его используешь. Ловушка — помечать всё 'use client' по привычке — ты получаешь тот же JS bundle что и обычный SPA, но с дополнительной сложностью. Правило: если в компоненте нет onClick, useState, useEffect и браузерных API, он должен быть Server Component.

Викторина

В Next.js 15, где должен жить useState?

Викторина

Без Suspense, как медленная data dependency влияет на TTFB в SSR?

Расставь шаги по порядку

Упорядочи события для RSC страницы с Suspense streaming:

  1. 1 Пользователь кликает по ссылке; браузер отправляет HTTP запрос на сервер
  2. 2 Сервер рендерит шелл лэйаута (async данные не нужны)
  3. 3 Чанк HTML шелла отправляется по response stream — TTFB
  4. 4 Сервер параллельно запускает async fetch'и внутри Suspense boundaries
  5. 5 Каждый fetch разрешается; сервер рендерит и стримит эту секцию
  6. 6 Браузер заменяет Suspense fallbacks на реальный контент по мере прихода чанков
  7. 7 LCP срабатывает когда самый большой визуальный элемент появляется
Вспомните перед уходом
  1. 01
    Что такое RSC Payload и для чего он используется?
  2. 02
    Почему Suspense streaming даёт более низкий TTFB, чем блокирующий SSR?
  3. 03
    Что такое dual-fetch ловушка и как её исправить?
Итог

React Server Components выполняются целиком на сервере — без useState, без браузерных API, нуль клиентского JS. Async Server Components fetch’ят данные при запросе; Suspense boundaries позволяют шеллу лэйаута стримиться за ~80ms, пока каждая секция данных приходит независимо. Директива ‘use client’ должна быть у листьев дерева, помечая только компоненты которым реально нужна интерактивность. Mixed RSC + client cache приложения должны использовать dehydration/rehydration (дегидрацию/регидрацию серверного кэша) для избежания dual-fetch ловушки. В Next.js 15 App Router эта архитектура — по умолчанию; bundle может уменьшиться с 240KB до 60KB перенеся статические display компоненты на server-side. Теперь, когда на code review ты увидишь 'use client' на компоненте без onClick или useState, — флагнешь это сразу и предложишь конкретную замену.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено
Связанные уроки
встречается в228

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.