Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Шаблони серверних і клієнтських компонентів у Next.js». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Шаблони серверних і клієнтських компонентів (Server/Client Components)** визначають, де в Next.js застосунку відбувається рендеринг - на сервері чи в браузері. ```tsx // Серверний компонент - дані з БД, JS не відправляється async function Page() { const data = await db.getPosts(); return <PostGrid posts={data} />; // Тільки HTML } ``` **Головне правило:** за замовчуванням - серверні компоненти; `"use client"` тільки там де потрібна взаємодія.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Шаблони серверних і клієнтських компонентів (Server/Client Components)** у Next.js розподіляють роботу між Node.js і браузером: сервер відправляє готовий HTML, браузер отримує JS тільки там, де потрібна взаємодія. ## Теорія ### TL;DR - Серверні компоненти (Server Components) рендеряться на сервері та відправляють HTML - жодного JS-бандлу для них - Клієнтські компоненти (Client Components) з директивою `"use client"` гідрують у браузері та відповідають за стан, події, API браузера - За замовчуванням - серверний компонент; `"use client"` додавай тільки де потрібні хуки або DOM - Клієнтські компоненти мають бути листями дерева, а не обгортками цілих сторінок - Серверний компонент може містити клієнтський через `children`; зворотне - ні ### Короткий приклад ```tsx // app/page.tsx - Серверний компонент, дані з сервера async function Page() { const posts = await fetch('https://api.example.com/posts').then(r => r.json()); return <PostList posts={posts} />; // Відправляє HTML, жодного JS для цього файлу } // components/PostItem.tsx - Клієнтський компонент, обробляє лайки 'use client'; import { useState } from 'react'; function PostItem({ post }: { post: Post }) { const [likes, setLikes] = useState(0); return ( <li> {post.title} <button onClick={() => setLikes(likes + 1)}>👍 {likes}</button> </li> ); } // Сервер рендерить весь список як HTML. Браузер гідрує тільки кнопки лайків. ``` Сервер відповідає за дані та початковий HTML. Браузер підключається лише на кнопці. ### Коли що використовувати - Отримання даних або доступ до БД: серверний компонент - прямий доступ, секрети не потрапляють у браузер - Статичний контент, SEO: серверний компонент - готовий HTML для пошукових роботів - `useState`, `useEffect`, інші хуки: клієнтський компонент - браузерні можливості React - Обробники подій (`onClick`, `onChange`): клієнтський компонент - потрібен DOM - API браузера (`localStorage`, `window`, `document`): клієнтський компонент - сервер не має браузерного контексту - Зменшити розмір JS-бандлу: серверний компонент - жодного JS для гідрації ### Порівняльна таблиця | Аспект | Серверний компонент | Клієнтський компонент | |--------|--------------------|-----------------------| | Виконання | Node.js (SSR/RSC) | Браузер (гідрація) | | JS-бандл | Не відправляється | Повний JS + гідрація | | Отримання даних | Прямий `async/await`, БД | `fetch()`, SWR, TanStack Query | | Інтерактивність | Відсутня | Повна (стан, події) | | Children | Може містити клієнтські | Не може імпортувати серверні | | Секрети | Безпечно в env | Ризик потрапити в бандл | | Типове застосування | Дашборд з БД, SEO-блог | Форми, модалки, меню | ### Патерн 1: Клієнтські компоненти вниз до листів Директива `"use client"` має стояти біля найменшого можливого шматка UI. Сторінка блогу - переважно статичний HTML; тільки кнопка поділитися потребує браузера. ```tsx // app/blog/[slug]/page.tsx - Серверний компонент export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; const post = await getPost(slug); // Прямий виклик до БД, API-ключ не потрапить у браузер return ( <article> <h1>{post.title}</h1> <p>{post.content}</p> {/* Статичний HTML, жодного JS */} <ShareButton url={post.url} /> {/* Клієнтська межа тут */} </article> ); } // components/ShareButton.tsx - Клієнтський компонент 'use client'; export function ShareButton({ url }: { url: string }) { return <button onClick={() => navigator.share({ url })}>Поділитися</button>; } ``` Якщо поставити `"use client"` на весь `BlogPost`, доведеться завантажувати повне дерево як JS, використовувати `useEffect` для даних і втратити серверний HTML для SEO. Я бачив, як це опускало Lighthouse з 90+ до нижче 60 на контентних сайтах. ### Патерн 2: Серверні компоненти як children Клієнтський компонент може приймати серверні компоненти через `children`. Обгортка рендериться на клієнті; те, що передано всередину, досі виконується на сервері. Головне: передати їх із серверного батька, а не імпортувати всередині клієнтського файлу. ```tsx // components/Accordion.tsx - Клієнтський компонент 'use client'; import { useState } from 'react'; export function Accordion({ title, children }: { title: string; children: React.ReactNode }) { const [isOpen, setIsOpen] = useState(false); return ( <div> <button onClick={() => setIsOpen(!isOpen)}>{title}</button> {isOpen && <div>{children}</div>} </div> ); } // app/page.tsx - Серверний компонент export default async function Page() { const data = await fetchData(); // Виконується на сервері return ( <Accordion title="Деталі"> <ServerContent data={data} /> {/* Рендериться на сервері, передається як children */} </Accordion> ); } ``` Shadcn/UI використовує цей підхід: клієнтські інтерактивні обгортки, що приймають серверний контент як слоти. ### Патерн 3: Дані на сервері, взаємодія на клієнті Отримай весь набір даних у серверному компоненті та передай через серіалізовані props. Клієнтський компонент займається фільтрацією, сортуванням або пагінацією в пам'яті - без зайвих мережевих запитів. ```tsx // app/products/page.tsx - Серверний компонент export default async function ProductsPage() { const products = await getProducts(); // Один серверний запит return <ProductGrid products={products} />; } // components/ProductGrid.tsx - Клієнтський компонент 'use client'; export function ProductGrid({ products }: { products: Product[] }) { const [filter, setFilter] = useState(''); const filtered = products.filter(p => p.name.includes(filter)); return ( <div> <input value={filter} onChange={e => setFilter(e.target.value)} /> {filtered.map(p => <ProductCard key={p.id} product={p} />)} </div> ); } ``` Props через межу сервер-клієнт мають бути серіалізованими: рядки, числа, прості об'єкти, масиви. Функції через цю межу не проходять. ### Патерн 4: Context Providers React Context потребує клієнтського компонента. Виноси провайдери в окремий файл і підключай їх із серверного layout. Children, передані ззовні, залишаться серверними. ```tsx // app/providers.tsx - Клієнтський компонент 'use client'; import { ThemeProvider } from 'next-themes'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; const queryClient = new QueryClient(); export function Providers({ children }: { children: React.ReactNode }) { return ( <ThemeProvider> <QueryClientProvider client={queryClient}> {children} </QueryClientProvider> </ThemeProvider> ); } // app/layout.tsx - Серверний компонент import { Providers } from './providers'; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html> <body> <Providers> {children} {/* Залишаються серверними компонентами */} </Providers> </body> </html> ); } ``` `Providers` - клієнтський файл. Сам layout залишається серверним. Children, що приходять із layout, рендеряться на сервері, бо вони передані ззовні клієнтської межі, а не імпортовані всередині неї. ### Типові помилки **Отримання даних у клієнтському компоненті замість серверного батька:** ```tsx // Погано - подвійний запит, роздутий бандл, мерехтіння порожнього контенту 'use client'; export function PostList() { const [posts, setPosts] = useState([]); useEffect(() => { fetch('/api/posts').then(r => r.json()).then(setPosts); }, []); return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>; } // Добре - серверний батько отримує дані, передає як props export default async function Page() { const posts = await fetch('https://api.example.com/posts').then(r => r.json()); return <PostList posts={posts} />; // PostList тільки рендерить, useEffect не потрібен } ``` **`"use client"` у кореневому layout:** ```tsx // Погано - весь застосунок стає клієнтським JS // app/layout.tsx 'use client'; // Все нижче тепер гідрується ``` Кореневий layout має залишатися серверним компонентом. Виноси в окремі файли лише те, що справді потребує браузера. **Передача функції як prop із сервера на клієнт:** ```tsx // Погано - функції не серіалізуються через RSC-межу export default function Server() { return <ClientComp onClick={() => console.log('привіт')} />; // Помилка в рантаймі } // Добре - обробник визначається всередині клієнтського компонента 'use client'; export function ClientComp() { return <button onClick={() => console.log('привіт')}>Натиснути</button>; } ``` Для серверних мутацій, ініційованих клієнтом, використовуй Server Actions: позначай функцію `'use server'` і передавай у `action` форми. **Виклик API браузера в серверному компоненті:** ```tsx // Погано - сервер не має window або localStorage export default function Theme() { localStorage.setItem('theme', 'dark'); // TypeError у рантаймі на Vercel } ``` Будь-який виклик `window`, `localStorage` або `document` переноси в клієнтський компонент. ### Де зустрічається - Vercel Dashboard: серверні компоненти тягнуть дані про деплойменти з БД; перемикачі та дропдауни - клієнтські - Shadcn/UI: інтерактивні компоненти (кнопки, діалоги, дропдауни) мають `"use client"`, використовуються всередині серверних сторінок - NextAuth: перевірка сесії через `auth()` у серверних компонентах; аватар користувача з меню - клієнтський компонент - Payload CMS: таблиці адмінки рендеряться на сервері; редактор Lexical - клієнтський компонент ### Питання на співбесіді **Q:** Що станеться, якщо імпортувати серверний компонент усередині клієнтського? **A:** Next.js кине помилку. Клієнтські компоненти не можуть безпосередньо імпортувати серверні. Використовуй патерн `children` - передавай серверний компонент із серверного батька. **Q:** Чи може клієнтський компонент отримувати дані? **A:** Так, через `useEffect` + `fetch`, SWR або TanStack Query. Але для початкових даних сторінки це дає зайвий мережевий запит і мерехтіння порожнього контенту, яке серверний компонент повністю усуває. **Q:** Що таке RSC payload і чим він відрізняється від HTML? **A:** Це бінарний потік, який сервер відправляє разом із початковим HTML. Він містить props компонентів і посилання, щоб браузерний React-reconciler міг гідрувати клієнтські піддерева без повторного запиту даних. **Q:** Чим Server Actions відрізняються від API routes? **A:** Server Actions - це функції з директивою `'use server'`, що запускаються на сервері при виклику через форму або клієнтську подію. Вони працюють без JavaScript (progressive enhancement). API routes - окремі HTTP-ендпоінти, які завжди потребують явного `fetch`. **Q:** (Senior) Чому серверні компоненти можна передавати як `children` клієнтським, але не імпортувати всередині них? **A:** Коли серверний компонент передається як `children`, сервер вже відрендерив його в RSC payload до того, як клієнтський компонент запустився. Клієнтський компонент отримує непрозоре посилання, а не функцію компонента, тому межа не порушується. Прямий імпорт затягнув би серверний модуль у клієнтський бандл, що React забороняє. ## Приклади ### Дашборд із серверними даними та клієнтською дією ```tsx // app/dashboard/page.tsx - Серверний компонент // Прямий доступ до БД - жодного витоку ключів, жодної зайвої обгортки export default async function Dashboard() { const metrics = await db.query('SELECT * FROM metrics ORDER BY date DESC LIMIT 30'); return ( <main> <h1>Dashboard</h1> <StatsGrid metrics={metrics} /> {/* Сервер: чистий HTML */} <ExportButton data={metrics} /> {/* Клієнт: File API браузера */} </main> ); } // components/ExportButton.tsx - Клієнтський компонент 'use client'; export function ExportButton({ data }: { data: Metric[] }) { const handleExport = () => { const blob = new Blob([JSON.stringify(data)], { type: 'application/json' }); const url = URL.createObjectURL(blob); // API тільки браузера const link = document.createElement('a'); link.href = url; link.download = 'metrics.json'; link.click(); }; return <button onClick={handleExport}>Експортувати JSON</button>; } // Сервер стримить повний дашборд як HTML. Браузер гідрує тільки кнопку. ``` ### Сторінка блогу з клієнтськими коментарями ```tsx // app/blog/[slug]/page.tsx - Серверний компонент import { getPost } from '@/lib/posts'; import { CommentSection } from './CommentSection'; export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; const post = await getPost(slug); // Виклик до БД на сервері, секрети не потрапляють у браузер return ( <article> <h1>{post.title}</h1> <div dangerouslySetInnerHTML={{ __html: post.content }} /> <CommentSection postId={post.id} /> </article> ); } // app/blog/[slug]/CommentSection.tsx - Клієнтський компонент 'use client'; import { useState } from 'react'; export function CommentSection({ postId }: { postId: string }) { const [comments, setComments] = useState<{ id: string; text: string }[]>([]); const [input, setInput] = useState(''); const handleAdd = () => { if (!input.trim()) return; setComments(prev => [...prev, { id: Date.now().toString(), text: input }]); setInput(''); }; return ( <section> <h2>Коментарі</h2> <input value={input} onChange={e => setInput(e.target.value)} placeholder="Додати коментар" /> <button onClick={handleAdd}>Опублікувати</button> <ul>{comments.map(c => <li key={c.id}>{c.text}</li>)}</ul> </section> ); } // HTML статті серверний - для SEO. Блок коментарів гідрується незалежно. ``` ### Server Action для видалення запису ```tsx // app/posts/actions.ts - Server Action 'use server'; import { db } from '@/lib/db'; export async function deletePost(formData: FormData) { const id = formData.get('id') as string; await db.posts.delete({ where: { id } }); } // app/posts/PostList.tsx - Клієнтський компонент 'use client'; import { deletePost } from './actions'; export function PostList({ posts }: { posts: Post[] }) { return ( <ul> {posts.map(post => ( <li key={post.id}> {post.title} <form action={deletePost}> <input type="hidden" name="id" value={post.id} /> <button type="submit">Видалити</button> </form> </li> ))} </ul> ); } // deletePost серіалізує дані через FormData і виконується на сервері. // Працює навіть без увімкненого JavaScript - progressive enhancement. ```Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.