lenec ru

← все посты

React Server Components в App Router: где они уместны, а где ломают DX

12K

Я сначала тоже ненавидела server components. Год назад мы переезжали с pages router на app router в Next.js, и первые две недели я ходила и бубнила, что всё это сломанная абстракция. Потом разобралась. Сейчас в проде живут оба моих больших приложения на App Router, и я могу спокойно сказать: где RSC реально снимают боль, а где они добавляют столько граблей, что хочется вернуть getServerSideProps.

Эта статья — не «введение в RSC». Их объяснений в сети уже больше, чем разработчиков, которые их щупали. Это про практику: какие сценарии RSC закрывают чисто, а где из-за них DX обваливается так, что ты теряешь день на одно поле формы.

Короткая база, чтобы говорить на одном языке

В App Router каждый файл по умолчанию серверный. Серверный компонент рендерится на сервере, отдаёт HTML и сериализованное дерево, в браузер не уезжает ни строчки его JS. Чтобы сделать компонент клиентским, ставишь "use client" в начало файла, и вместе с ним клиентскими становятся все импортированные из него модули.

Главное правило, которое я повторяю всем джунам: граница «серверный/клиентский» проходит по импортам, а не по визуальной иерархии. Если серверный компонент рендерит клиентский, это нормально. Если клиентский импортирует серверный — нет, это будет клиентский компонент с другим именем. Поэтому в App Router выгодно держать клиентские «листья» как можно мельче и пушить серверную часть наверх.

Где RSC реально хороши

1. Страницы-каталоги и листинги с фильтрами в URL

Самый честный сценарий. Нам прилетел тикет — оптимизировать листинг на 5К карточек товаров с фильтрами по категории, цене и наличию. На pages router это была страница с getServerSideProps, кучей пропсов и клиентским useState для управления фильтрами. На App Router всё это превращается в один серверный компонент, который читает параметры из searchParams.

// app/catalog/page.tsx
type SearchParams = {
  category?: string;
  min?: string;
  max?: string;
};

export default async function CatalogPage({
  searchParams,
}: {
  searchParams: Promise<SearchParams>;
}) {
  const { category, min, max } = await searchParams;
  const products = await getProducts({ category, min, max });

  return (
    <section>
      <FiltersBar />
      <ProductGrid products={products} />
    </section>
  );
}

Тут ничего не уезжает в клиентский бандл, кроме FiltersBar (он клиентский, потому что меняет URL через useRouter). Карточки товаров — серверные, и в бандле от них пусто. На реальном проекте у нас бандл просел с 280 КБ до 95 КБ gzipped. Это не маркетинг, это просто факт того, что 80% UI больше не нужно гонять через JS-парсер браузера.

2. Страницы с тяжёлыми данными из нескольких источников

Когда тебе на одной странице нужны данные из БД, кеша и внешнего API, серверный компонент с async/await читается линейно и не плодит каскад useEffect.

export default async function DashboardPage() {
  const [user, stats, news] = await Promise.all([
    getCurrentUser(),
    getStats(),
    fetch("https://api.example.com/news", {
      next: { revalidate: 300 },
    }).then((r) => r.json()),
  ]);

  return <Dashboard user={user} stats={stats} news={news} />;
}

Это не «революционно». Это просто async-компонент. Но после года жизни с useQuery на каждый чих, такая прямая работа с данными ощущается как глоток воды.

3. Контент-страницы и SEO

Блоги, документация, посадочные. Тут серверный рендер всегда был естественным, RSC просто упаковали его в более удобный формат: контент рендерится на сервере, а интерактивные куски (поиск, тёмная тема, форма подписки) живут в маленьких клиентских компонентах.

4. Авторизованный контент без флешей

Раньше я делала так: страница приходит как заглушка, в useEffect запрашиваем профиль, рендерим. Пользователь видит мигание. На RSC ты в серверном компоненте читаешь cookie через cookies(), дёргаешь сессию, рендеришь сразу нужный вариант. Никаких заглушек.

Где RSC ломают DX

Теперь честная половина. Места, где я плевалась и где плююсь до сих пор.

1. Сложные интерактивные формы

RSC и формы — отдельная история. Идея с Server Actions хорошая на бумаге: пишешь функцию, помечаешь "use server", дёргаешь её прямо из формы. На практике любая форма сложнее логина — это танец из useFormStatus, useActionState, ручной валидации на клиенте, дублирующей серверной, и обработки оптимистичных апдейтов.

"use client";

import { useActionState } from "react";
import { createComment } from "./actions";

type State = { error?: string; ok?: boolean };

export function CommentForm({ postId }: { postId: string }) {
  const [state, action, pending] = useActionState<State, FormData>(
    async (_prev, formData) => createComment(postId, formData),
    {},
  );

  return (
    <form action={action}>
      <textarea name="text" required />
      <button disabled={pending}>Отправить</button>
      {state.error && <p role="alert">{state.error}</p>}
    </form>
  );
}

Выглядит чисто. Но как только тебе нужно: показать спиннер на конкретном поле, обновить три разных списка после сабмита, откатить изменение при ошибке сети — ты упираешься в то, что Server Actions это плоская абстракция «отправь и получи ответ», а UI-стейт в форме сложнее. Я в итоге для таких случаев откатилась на обычные REST-эндпоинты и TanStack Query на клиенте. Жить стало легче.

2. Графики, дашборды, всё с интерактивом

Любая страница, где половина UI — это графики, дроп-зоны, drag-and-drop, виртуальные списки. RSC тут не помогает: всё это клиентский код. Зато App Router ставит палки в колёса: контекст-провайдеры приходится оборачивать в отдельные клиентские компоненты, серверный layout не может передать ref в клиентский ребёнок, и каждое второе сторонее решение требует "use client" на корне страницы.

В таких случаях я честно ставлю "use client" на всю страницу и забываю про серверные компоненты. Не пытайтесь героически разрезать дашборд на серверные и клиентские куски, если 95% его — это интерактив. Сэкономите два часа реального времени на оптимизацию ради 0.05 секунды на странице, которую открывают раз в неделю.

3. Передача функций и классов через границу

Через границу серверный→клиентский можно передавать только сериализуемые пропсы. Это значит: ни функций (кроме Server Actions), ни классов, ни Date в некоторых случаях, ни Map/Set. Когда у тебя сложный объект с методами — приходится либо превращать его в DTO, либо делать обёртку.

// нельзя
<ClientComponent user={userInstance} />

// можно
<ClientComponent user={userInstance.toJSON()} />

На больших проектах это превращается в отдельный слой DTO, который ты пишешь и поддерживаешь. На small/medium это терпимо. На большом — добавляет ощутимый налог.

4. Отладка

Стек-трейс ошибки в Server Action или в серверном компоненте часто заканчивается в скомпилированном чанке Next.js, и понять, на какой строке твоего кода всё умерло, нужно отдельным навыком. Source maps в dev-режиме помогают, но не всегда. Особенно весело отлаживать гидрацию: компонент серверный, на сервере отрендерилось одно, на клиенте догидрировало другое, в консоли красным «Hydration mismatch». Я держу под рукой памятку, что обычно это Date.now(), Math.random(), или localStorage в render.

5. Сторонние библиотеки

Не все библиотеки готовы к RSC. Особенно те, что используют useLayoutEffect на верхнем уровне, или что-то делают в импорт-сайд-эффектах. Часть решений — обернуть в "use client" файл-обёртку. Часть — ждать апдейта мейнтейнера. У меня в трекере висит ровно одна такая библиотека уже полгода.

Практическое правило, которым я пользуюсь

Перед тем, как начать страницу, я отвечаю на три вопроса:

  1. Сколько процентов UI на странице — это интерактив (формы, графики, drag-and-drop)?
  2. Откуда приходят данные: одна точка или несколько?
  3. Нужен ли SEO?

Если интерактива <30%, данных много, SEO нужен — RSC отлично заходит. Если интерактива >70% — ставлю "use client" на корне страницы и не страдаю. Если посередине — режу по логическим границам: каркас серверный, интерактивные куски клиентские.

Что я бы хотела видеть в App Router в идеале

Чтобы было честно: RSC не дописанная штука. Я бы хотела:

  • Нормальную передачу типизированных Server Actions с автокомплитом по полям формы.
  • Возможность переиспользовать одну и ту же функцию и на сервере, и на клиенте без танца с "use server".
  • Лучшую поддержку Suspense в реальных сценариях, а не в туториалах.

Но даже сейчас, со всеми оговорками, на правильных задачах RSC экономит мне недели работы и килобайты бандла. На неправильных — крадёт дни. Главное — научиться отличать одно от другого.

Что запомнить

RSC — не серебряная пуля и не «новый стандарт, к которому все обязаны прийти». Это инструмент с конкретной зоной применимости: страницы с большим объёмом серверных данных, контент, листинги, авторизованные дашборды без жирного интерактива. На сложных формах и интерактивных приложениях — не насилуйте себя. Поставьте "use client" и работайте как раньше, никто не умрёт.

Если переезжаешь с pages router — не пытайся разом перенести весь проект. Я переносила страницами, начиная с самых простых: контент, потом листинги, в конце — формы и дашборды. Это заняло три месяца на проекте средней руки, и каждый шаг был обратимым.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.