Весна 2024 года. На одном из production-проектов команда вводит Server Actions в Next.js App Router. Через три часа разработчики обнаруживают: после отправки формы сброс полей не работает, хуки useState моргают undefined, а сервер нагружен на 100%. Анализ показывает главную ошибку — непонимание фундаментальной разницы между серверными и клиентскими компонентами.
Этот кейс — лишь один из тысяч, показывающих болезненность перехода на новую модель вычислений в React-экосистеме. Для современных разработчиков овладеть секретами React Server Components (RSC) — не прихоть, а необходимость.
Кардинальный сдвиг в ментальной модели
Традиционные React-компоненты одинаково исполняются на клиенте и сервере (при SSR). RSC — совершенно иная сущность. Они:
- Выполняются исключительно на сервере
- Не содержат состояния и хуков
- Рендерят не виртуальные DOM-элементы, а специальный RSC-поток
Рассмотрим практическое различие на базе данных форм:
// Клиентский компонент
'use client';
import { useState } from 'react';
export default function UserForm() {
const [email, setEmail] = useState('');
return (
<form>
<input
value={email}
onChange={e => setEmail(e.target.value)}
/>
</form>
);
}
Теперь серверный вариант:
import { db } from '@lib/database';
import { redirect } from 'next/navigation';
export default async function UserProfile({ userId }) {
const user = await db.user.findUnique({ where: { id: userId } });
const updateAction = async (formData: FormData) => {
'use server';
await db.user.update({
where: { id: userId },
data: { name: formData.get('name') }
});
redirect(`/users/${userId}/updated`);
};
return (
<form action={updateAction}>
<input
name="name"
defaultValue={user.name}
/>
<button type="submit">Обновить</button>
</form>
);
}
Ключевые отличия:
- Получение данных происходит при рендеринге компонента
- Отсутствие
useState
, вместо него - прямое чтение из базы - Исключение перерисовок при вводе (
defaultValue
вместоvalue
)
Типичные ошибки и их решения
Ошибка 1: Хуки состояния в серверном компоненте
Попытка добавить useState
в RSC вызовет ошибку сборки. Это формально, но многие упускают:
// ОШИБКА!
import { useState } from 'react'; // Включается только в клиентские компоненты
export default function ServerComponent() {
const [count, setCount] = useState(0); // Cannot access 'useState' on server
...
}
Решение:
Структурировать компоненты через композицию. Серверный родитель берёт данные, клиентский потомок работает с интерфейсом:
// ServerParent.js
export default async function Parent() {
const data = await fetchData();
return <ClientChild initialData={data} />;
}
// ClientChild.js
'use client';
export default function Child({ initialData }) {
const [data, setData] = useState(initialData);
...
}
Ошибка 2: Нарушение принципов сериализации
Передача неподдерживаемых объектов через пропсы вызовет ошибки:
// Передаем Date-объект сервер→клиент
export default async function Component() {
const date = new Date();
return <ClientComponent date={date} />; // TypeError: Only plain objects can be passed
}
Исправление:
// Сериализуем в примитивы
export default async function Component() {
const date = new Date();
return <ClientComponent timestamp={date.getTime()} />;
}
Ошибка 3: Неосторожность с пограничными процессами
Допустим, нужен Transition на загрузке. В серверном компоненте useTransition
недоступен. Неверно:
// Серверный компонент - не работает!
import { useTransition } from 'react';
export async function Button() {
const [isPending, startTransition] = useTransition(); // Error!
...
}
Правильный подход:
// Серверный компонент передает действие клиенту
async function performAction() { ... }
// Клиентская обертка
'use client';
export function ActionButton() {
const [isPending, startTransition] = useTransition();
return (
<button
onClick={() => startTransition(() => performAction())}
disabled={isPending}
>
{isPending ? 'Загрузка...' : 'Отправить'}
</button>
);
}
Расширенные стратегии для сложных систем
Оптимизация свалки пропсов (Props Drilling)
Если серверным компонентам нужно передать данные через несколько слоев, используйте паттерн с дедупликацией запросов:
// Контекст – только клиентский инструмент! Не для RSC.
// Вместо этого – двойной извлекающий компонент:
import { db } from '@/lib/db';
export async function VendorDropdown({ vendorId }) {
const vendor = await db.vendor.findUnique({ where: { id: vendorId } });
return <BaseDropdown items={vendor.contacts} />;
}
// Имбиринг данных другого типа:
export async function UserProfile({ userId }) {
const user = await db.user.findUnique({ where: { id: userId } });
return (
<section>
<UserInfo user={user} />
{/* VendorDropdown напрямую запрашивает данные */}
<VendorDropdown vendorId={user.vendorId} />
</section>
);
}
Эффективный Time-to-Interactive (TTI)
Для критического пути используйте стратегию приоритезации:
// Основной layout.js
export default function Layout({ children }) {
return (
<html>
<body>
{/* 3. Пользователь видит интерфейс сразу */}
<TopBar />
<Sidebar />
{/* 1. Блокаирующий контент загружаем в Suspense */}
<Suspense fallback={<LoadingSkeleton />}>
{/* 2. Детям разрешаем блокировать рендер */}
{children}
</Suspense>
</body>
</html>
);
}
// Параллелизация при загрузке
import { Suspense } from 'react';
export default function Page() {
return (
<>
<Suspense fallback={<ProfilePlaceholder />}>
<ProfileSection />
</Suspense>
<Suspense fallback={<StatsLoader />}>
<AnalyticsSection />
</Suspense>
</>
);
}
Оптимизация гидрации скелетонами
Проблема: при восстановлении состояния после SSR компоненты с асинхронными данными "подпрыгивают". Решение:
export async function ProductSection() {
const products = await db.products.findMany({ take: 10 });
// Запомните! В RSC нельзя идти в дом. Выводите plain HTML
return (
<section>
{products.map(p => (
<ProductCard
key={p.id}
name={p.name}
price={p.price}
stockStatus={p.inStock ? 'in-stock' : 'preorder'} <!-- Постоянный класс -->
/>
))}
</section>
);
}
При выводе карточек с вычисляемыми классами сохраняйте их состояния на сервере и клиенте детерминированными. Избегайте вычислений динамических классов в рантайме.
Глубокое понимание инфраструктурных ограничений
-
Что точно не работает в RSC:
useEffect
,useState
,useContext
- API браузера:
localStorage
,window
- События типа
onClick
-
Частично недоступное:
- Модули с побочными эффектами (например, мониторинг Sentry)
- Библиотеки, не изолировавшие DOM-манипуляции
-
Где хранить конфиденциальную логику: Server Actions позволяет прятать чувствительные операции:
'use server';
export async function fetchCustomerData(userId: string) {
const session = await validateSession();
if (!session) throw new Error('Unauthorized');
const data = await db.customer.findFirst({
where: { userId, orgId: session.orgId }
});
return data;
}
// Клиентский вызов из формы:
import { fetchCustomerData } from '@/actions';
function ClientForm() {
const data = await fetchCustomerData('user-456');
...
}
Заключение: баланс как искусство
Интеграция Server Components в Next.js — не финал, а начало компромиссов:
- Когда использовать RSC: задача относится к данным (витрина каталога, статистика по постам)
- Когда клиентские компоненты: есть клиентская интерактивность (драг-н-дроп, чат)
Критически важный шаг: разделяйте логику с клеймов 'use server'
и 'use client'
. Скорость ввода разработчиков может быть обратно пропорциональна отладке межблочных соединений.
Совет инженерам после эксплуатации полутора десятков проектов на RSC: сразу согласуйте правила именования файлов. Серверные – UserCard.server.jsx
. Клиентские – UserCard.client.jsx
. Один стиль для всех команд. Это сэкономит сотни часов реализации.
На практике достигнуть 300% ускорения Time-to-Interactive на сложном ресурсе невозможно без балансировки между двумя типами компонентов. Но сделав этот переход аккуратно, вы не встретите прессинг расширений воли архитекторов в проектах.