Перейти к содержимому

5. Server vs Client Components

Это самая важная концепция в современном Next.js. React Server Components (RSC) меняют способ мышления о рендеринге. Давай разберём раз и навсегда! 🧠

Старый React (всё на клиенте):
Browser → загружает JS → React рендерит HTML → показывает пользователю
Next.js RSC (разделение):
Server → рендерит Server Components → отправляет HTML + Client Components
Browser → получает готовый HTML → "оживляет" Client Components (hydration)

В App Router все компоненты серверные по умолчанию. Без единой директивы:

// app/products/page.tsx — Server Component!
// Никакого 'use client', никаких пометок
import { db } from '@/lib/db'; // 🔒 Безопасно — только на сервере
import { cache } from 'react';
// async/await прямо в компоненте!
export default async function ProductsPage() {
// Прямой запрос к базе данных
const products = await db.product.findMany({
include: { category: true },
orderBy: { createdAt: 'desc' },
take: 20,
});
return (
<main>
<h1>Товары ({products.length})</h1>
<ul>
{products.map(product => (
<li key={product.id}>
{product.name} — {product.price}₽
</li>
))}
</ul>
</main>
);
}

Что могут Server Components:

// 1. Асинхронные запросы прямо в компоненте
async function UserCard({ userId }: { userId: string }) {
const user = await fetchUser(userId); // Нет useEffect!
return <div>{user.name}</div>;
}
// 2. Прямой доступ к серверным ресурсам
import { db } from '@/lib/prisma';
import { cookies } from 'next/headers';
import { headers } from 'next/headers';
async function SecureData() {
const cookieStore = await cookies();
const token = cookieStore.get('auth-token');
const headersList = await headers();
const userAgent = headersList.get('user-agent');
// Прямо к БД!
const data = await db.secret.findMany();
return <div>{data.length} записей</div>;
}
// 3. Доступ к переменным окружения (серверным!)
async function Config() {
const apiKey = process.env.SECRET_API_KEY; // Не утечёт в браузер!
const data = await fetch('https://api.service.com', {
headers: { 'X-API-Key': apiKey! }
});
return <div>Данные загружены</div>;
}
// 4. Импорт heavy библиотек без влияния на bundle
import { marked } from 'marked'; // Не попадёт в JS-бандл!
import { highlight } from 'shiki'; // Только на сервере
async function MarkdownRenderer({ content }: { content: string }) {
const html = await marked(content);
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

Что НЕ могут Server Components:

// ❌ useState — нет
// ❌ useEffect — нет
// ❌ useRef — нет
// ❌ Event handlers (onClick, onChange...) — нет
// ❌ Browser APIs (window, document, localStorage) — нет
// ❌ Context (useContext) — нет (можно передавать через props)
// ❌ Class components с lifecycle методами — нет
// Всё это только в Client Components!

Client Components нужны для интерактивности. Пометь их директивой 'use client':

components/counter.tsx
'use client'; // Эта директива делает компонент клиентским
import { useState, useEffect } from 'react';
export function Counter({ initialCount = 0 }: { initialCount?: number }) {
const [count, setCount] = useState(initialCount);
useEffect(() => {
document.title = `Счётчик: ${count}`;
}, [count]);
return (
<div>
<p>Счётчик: {count}</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
<button onClick={() => setCount(c => c - 1)}>-1</button>
</div>
);
}

Что могут Client Components:

'use client';
// ✅ Все хуки React
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
// ✅ Event handlers
function Form() {
const [value, setValue] = useState('');
return (
<form onSubmit={e => {
e.preventDefault();
console.log(value);
}}>
<input onChange={e => setValue(e.target.value)} value={value} />
<button type="submit">Отправить</button>
</form>
);
}
// ✅ Browser APIs
function LocalStorageExample() {
const [theme, setTheme] = useState<string>(() => {
// Работает только на клиенте!
return typeof window !== 'undefined'
? localStorage.getItem('theme') ?? 'light'
: 'light';
});
const toggleTheme = () => {
const next = theme === 'light' ? 'dark' : 'light';
setTheme(next);
localStorage.setItem('theme', next);
};
return <button onClick={toggleTheme}>Тема: {theme}</button>;
}
// ✅ Third-party клиентские библиотеки
import { motion } from 'framer-motion';
import { Swiper, SwiperSlide } from 'swiper/react';
function AnimatedCard() {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
whileHover={{ scale: 1.05 }}
>
Анимированная карточка
</motion.div>
);
}

Ключевое правило: 'use client' создаёт границу. Всё ниже этой границы — клиентский код:

// app/page.tsx — Server Component
import { ClientSearch } from './ClientSearch'; // Импорт клиентского компонента
export default async function Page() {
const posts = await fetchPosts(); // Серверный код
return (
<div>
<h1>Блог</h1>
<ClientSearch /> {/* Граница! Внутри — клиентский код */}
<PostList posts={posts} /> {/* Серверный компонент */}
</div>
);
}
components/ClientSearch.tsx
'use client'; // Граница начинается здесь
import { useState } from 'react';
// ВСЁ что импортируется здесь — тоже становится клиентским!
export function ClientSearch() {
const [query, setQuery] = useState('');
// ... логика поиска
}

Паттерн 1: Server Component как контейнер данных

// app/dashboard/page.tsx — Server Component
import { UserProfile } from './UserProfile'; // Client Component
export default async function DashboardPage() {
// Загружаем данные на сервере
const user = await db.user.findUnique({ where: { id: session.userId } });
const stats = await getStats(session.userId);
return (
<div>
{/* Передаём серверные данные в клиентский компонент */}
<UserProfile
user={user} // Сериализуемые данные!
stats={stats}
/>
</div>
);
}
// components/UserProfile.tsx — Client Component
'use client';
interface Props {
user: { id: string; name: string; email: string };
stats: { posts: number; views: number };
}
export function UserProfile({ user, stats }: Props) {
const [editing, setEditing] = useState(false);
return (
<div>
<h2>{user.name}</h2>
{/* Интерактивность на клиенте */}
<button onClick={() => setEditing(true)}>Редактировать</button>
</div>
);
}

Паттерн 2: Интерлив Server и Client

// ✅ Server Component может рендерить Client Component
// ✅ Client Component может рендерить Server Component через props children
// app/page.tsx — Server Component
import { InteractiveWrapper } from '@/components/InteractiveWrapper'; // Client
export default async function Page() {
const data = await fetchData();
return (
<InteractiveWrapper>
{/* Это — Server Component, переданный как children */}
<ServerDataDisplay data={data} />
</InteractiveWrapper>
);
}
// components/InteractiveWrapper.tsx — Client Component
'use client';
import { useState } from 'react';
export function InteractiveWrapper({ children }: { children: React.ReactNode }) {
const [expanded, setExpanded] = useState(false);
return (
<div>
<button onClick={() => setExpanded(!expanded)}>
{expanded ? 'Свернуть' : 'Развернуть'}
</button>
{expanded && children} {/* children — Server Component! */}
</div>
);
}
// children выполнены на сервере, WrapperClient — на клиенте

Паттерн 3: Context с RSC

providers/ThemeProvider.tsx
// Context работает только в Client Components!
'use client'; // Обязательно!
import { createContext, useContext, useState } from 'react';
const ThemeContext = createContext<{
theme: string;
toggle: () => void;
} | null>(null);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, toggle: () => setTheme(t => t === 'light' ? 'dark' : 'light') }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme должен быть внутри ThemeProvider');
return ctx;
}
// app/layout.tsx — Server Component (!)
import { ThemeProvider } from '@/providers/ThemeProvider';
export default function RootLayout({ children }) {
return (
<html>
<body>
{/* ThemeProvider — Client Component, но children могут быть серверными */}
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
);
}

Гидрация — процесс “оживления” серверного HTML на клиенте:

Сервер:
1. Рендерит HTML с данными
2. Сериализует состояние компонентов (React Server Component Payload)
3. Отправляет HTML + JS клиенту
Клиент:
4. Браузер показывает HTML (контент виден сразу!)
5. Загружается JavaScript (только клиентские компоненты!)
6. React "gидрирует" DOM — прикрепляет обработчики событий
7. Приложение становится интерактивным
// Ошибки гидрации — частая проблема!
// Возникает когда серверный и клиентский HTML различаются
// ❌ Плохо — Math.random() разный на сервере и клиенте
function BadComponent() {
return <div id={Math.random().toString()}>Контент</div>;
}
// ❌ Плохо — Date.now() разный
function BadDate() {
return <div>{new Date().toISOString()}</div>;
}
// ✅ Хорошо — используй useEffect для клиентского контента
'use client';
function GoodComponent() {
const [id, setId] = useState<string | null>(null);
useEffect(() => {
setId(Math.random().toString()); // Только на клиенте
}, []);
if (!id) return null;
return <div id={id}>Контент</div>;
}
// ✅ Или suppressHydrationWarning для элементов с динамическим контентом
function TimeDisplay() {
return (
<time suppressHydrationWarning>
{new Date().toISOString()}
</time>
);
}

Многие npm-пакеты написаны до RSC и используют клиентские APIs:

// ❌ Ошибка: библиотека использует useState внутри
import { SomeUILibComponent } from 'some-ui-lib';
// → Error: useState can only be used in Client Components
// ✅ Решение 1: Обернуть в Client Component
// components/ClientSomeUI.tsx
'use client';
import { SomeUILibComponent } from 'some-ui-lib';
export { SomeUILibComponent }; // Re-export как клиентский
// ✅ Решение 2: Создать wrapper
// components/ClientWrapper.tsx
'use client';
import { SomeUILibComponent } from 'some-ui-lib';
export function MyComponent(props: SomeProps) {
return <SomeUILibComponent {...props} />;
}
// Теперь можно использовать в Server Component:
import { MyComponent } from '@/components/ClientWrapper';

Server ComponentsClient Components
ДирективаНет (по умолчанию)'use client'
useState/useEffect
async/await❌ (useEffect)
DB / API secrets
onClick, onChange
Browser APIs
В bundle клиента❌ (не попадает!)

Правило большого пальца: Начни с Server Component. Добавь 'use client' только когда действительно нужно.