Пошук уроків, статей та іншого контенту
Комбінуйте серверні й клієнтські компоненти через children, slots та інші патерни композиції.
У Next.js з App Router компоненти за замовчуванням є серверними. Клієнтськими вони стають лише після додавання директиви "use client".
Композиція дає змогу поєднувати ці два типи компонентів без перенесення всієї частини інтерфейсу на клієнт.
Типова структура:
Server Component
└── Client Component
├── children: Server Component
└── slot: Server ComponentСерверний компонент може:
імпортувати серверні компоненти;
імпортувати клієнтські компоненти;
передавати серверну розмітку клієнтському компоненту через children або props-слоти.
Клієнтський компонент не повинен імпортувати серверний компонент напряму.
childrenchildren — найпростіший патерн композиції. Батьківський компонент відповідає за оболонку, а вміст передається ззовні.
Наприклад, клієнтська оболонка може керувати відкриттям і закриттям бічної панелі, а її вміст залишатиметься серверним.
// app/components/DashboardShell.tsx
"use client";
import { useState, type ReactNode } from "react";
type DashboardShellProps = {
children: ReactNode;
sidebar: ReactNode;
};
export default function DashboardShell({
children,
sidebar,
}: DashboardShellProps) {
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
return (
<div>
<header>
<button
type="button"
aria-expanded={isSidebarOpen}
onClick={() => setIsSidebarOpen((isOpen) => !isOpen)}
>
{isSidebarOpen ? "Сховати профіль" : "Показати профіль"}
</button>
</header>
<div style={{ display: "flex", gap: "24px", marginTop: "24px" }}>
{isSidebarOpen && (
<aside style={{ width: "240px" }}>
{sidebar}
</aside>
)}
<main style={{ flex: 1 }}>
{children}
</main>
</div>
</div>
);
}Компонент має "use client", тому що використовує useState та обробник onClick.
Водночас children і sidebar не є обов’язково клієнтськими компонентами. Це вже підготовлені елементи, які передав батьківський серверний компонент.
// app/components/UserSummary.tsx
export default function UserSummary() {
return (
<section>
<h2>Профіль</h2>
<p>Ім’я: Олена</p>
<p>Роль: Адміністраторка</p>
</section>
);
}// app/components/ActivityFeed.tsx
const activities = [
"Оновлено налаштування профілю",
"Створено новий проєкт",
"Додано учасника до команди",
];
export default function ActivityFeed() {
return (
<section>
<h1>Остання активність</h1>
<ul>
{activities.map((activity) => (
<li key={activity}>{activity}</li>
))}
</ul>
</section>
);
}Ці компоненти не мають "use client", отже вони серверні.
// app/page.tsx
import DashboardShell from "./components/DashboardShell";
import ActivityFeed from "./components/ActivityFeed";
import UserSummary from "./components/UserSummary";
export default function Page() {
return (
<DashboardShell sidebar={<UserSummary />}>
<ActivityFeed />
</DashboardShell>
);
}У цьому прикладі:
Page — серверний компонент;
DashboardShell — клієнтський компонент;
UserSummary — серверний компонент, переданий через слот sidebar;
ActivityFeed — серверний компонент, переданий через children.
Клієнтська оболонка відповідає лише за інтерактивність. Вона не знає, як створюються UserSummary та ActivityFeed, і не імпортує їх у своєму файлі.
children підходить для основного вмісту. Якщо компонент має кілька незалежних областей, використовуйте іменовані слоти.
Слот у цьому контексті — це звичайний prop, значенням якого є ReactNode.
type PageLayoutProps = {
header: ReactNode;
sidebar: ReactNode;
children: ReactNode;
footer?: ReactNode;
};Такий підхід корисний для компонентів із фіксованою структурою:
заголовок;
бічна панель;
основний вміст;
підвал;
панель дій.
// app/components/PageLayout.tsx
import type { ReactNode } from "react";
type PageLayoutProps = {
header: ReactNode;
sidebar: ReactNode;
children: ReactNode;
footer?: ReactNode;
};
export default function PageLayout({
header,
sidebar,
children,
footer,
}: PageLayoutProps) {
return (
<div>
<header>{header}</header>
<div style={{ display: "flex", gap: "24px", marginTop: "24px" }}>
<aside style={{ width: "220px" }}>{sidebar}</aside>
<main style={{ flex: 1 }}>{children}</main>
</div>
{footer && <footer style={{ marginTop: "24px" }}>{footer}</footer>}
</div>
);
}// app/page.tsx
import PageLayout from "./components/PageLayout";
import ActivityFeed from "./components/ActivityFeed";
import UserSummary from "./components/UserSummary";
function Header() {
return <h1>Панель керування</h1>;
}
function Footer() {
return <p>Останнє оновлення: сьогодні</p>;
}
export default function Page() {
return (
<PageLayout
header={<Header />}
sidebar={<UserSummary />}
footer={<Footer />}
>
<ActivityFeed />
</PageLayout>
);
}Усі компоненти в цьому прикладі можуть залишатися серверними. PageLayout не зобов’язаний бути клієнтським, якщо в ньому немає стану, ефектів або обробників подій.
Розглянемо важливу межу:
// Серверний компонент
import ClientShell from "./ClientShell";
import ServerContent from "./ServerContent";
export default function Page() {
return (
<ClientShell>
<ServerContent />
</ClientShell>
);
}Це дозволена композиція. Серверний компонент Page створює елемент ServerContent і передає його в ClientShell через children.
Однак такий варіант неправильний:
// ClientShell.tsx
"use client";
import ServerContent from "./ServerContent";
export default function ClientShell() {
return <ServerContent />;
}Після "use client" файл стає частиною клієнтської межі. Імпортувати серверний компонент безпосередньо з нього не можна.
Правильний підхід — передати серверний вміст із серверного батьківського компонента:
// ClientShell.tsx
"use client";
import type { ReactNode } from "react";
export default function ClientShell({
children,
}: {
children: ReactNode;
}) {
return <section>{children}</section>;
}// page.tsx
import ClientShell from "./ClientShell";
import ServerContent from "./ServerContent";
export default function Page() {
return (
<ClientShell>
<ServerContent />
</ClientShell>
);
}Через children або slot props можна передавати:
JSX-елементи;
серверні компоненти;
клієнтські компоненти;
текст;
масиви JSX-елементів;
умовно сформований вміст.
Наприклад:
<DashboardShell
sidebar={isAdmin ? <AdminPanel /> : <UserSummary />}
>
{showActivity ? <ActivityFeed /> : <EmptyState />}
</DashboardShell>Умова виконується в батьківському компоненті, який формує композицію.
Через клієнтську межу не слід передавати довільні несеріалізовані значення:
функції;
екземпляри класів;
об’єкти з циклічними посиланнями;
об’єкти, які неможливо передати в даних React Server Components.
Звичайні значення на кшталт рядків, чисел, boolean, масивів і простих об’єктів передавати можна. JSX через children або слот є окремим рекомендованим патерном композиції.
children і слотамиВикористовуйте children, коли компонент має одну основну область вмісту:
<Modal>
<LoginForm />
</Modal>Використовуйте іменовані слоти, коли областей кілька:
<PageLayout
header={<Header />}
sidebar={<Sidebar />}
footer={<Footer />}
>
<Content />
</PageLayout>Компонент-оболонка при цьому визначає структуру, але не визначає конкретний вміст кожної області.
Компонент-оболонка не повинен знати деталі всіх сторінок, які він відображає.
Не варто створювати універсальний клієнтський компонент, який імпортує та умовно рендерить усі можливі серверні компоненти:
// Невдалий підхід
"use client";
import Reports from "./Reports";
import Settings from "./Settings";
export default function ApplicationShell({ page }: { page: string }) {
if (page === "reports") {
return <Reports />;
}
return <Settings />;
}Краще передати потрібну сторінку через слот:
// Server Component
import ApplicationShell from "./ApplicationShell";
import Reports from "./Reports";
export default function ReportsPage() {
return (
<ApplicationShell>
<Reports />
</ApplicationShell>
);
}Тоді ApplicationShell відповідає лише за спільну клієнтську поведінку, а конкретний вміст залишається на серверній стороні.
Для більшості слотів достатньо ReactNode:
import type { ReactNode } from "react";
type ShellProps = {
children: ReactNode;
toolbar?: ReactNode;
sidebar?: ReactNode;
};Якщо слот обов’язковий, не додавайте ?:
type LayoutProps = {
header: ReactNode;
children: ReactNode;
};Якщо слот необов’язковий, передбачте відсутність значення:
export default function Layout({
header,
children,
footer,
}: LayoutProps) {
return (
<>
<header>{header}</header>
<main>{children}</main>
{footer && <footer>{footer}</footer>}
</>
);
}"use client" до всього дереваЯкщо додати "use client" до компонента верхнього рівня, усі його імпорти можуть потрапити до клієнтської межі. Це збільшує клієнтський JavaScript і може зробити серверну композицію неможливою.
Додавайте "use client" якомога ближче до компонента, якому справді потрібні:
useState;
useEffect;
браузерні API;
обробники подій;
інші клієнтські можливості.
Клієнтський компонент не повинен напряму імпортувати серверний. Передавайте серверний вміст із серверного батьківського компонента через children або слот.
Такий код може бути некоректним, якщо функція передається від серверного компонента до клієнтського:
<ClientButton onClick={() => console.log("Натиснуто")} />Інтерактивну функцію створюйте всередині клієнтського компонента або передавайте спеціально підтримувані серверні дії в тих сценаріях, де це передбачено архітектурою застосунку. Для базової композиції використовуйте JSX-слоти, а не функції-рендери.
Якщо компонент лише розміщує children, але не має стану або браузерної поведінки, "use client" йому не потрібен:
import type { ReactNode } from "react";
export default function Section({ children }: { children: ReactNode }) {
return <section>{children}</section>;
}Клієнтською має бути лише та частина композиції, яка справді взаємодіє з користувачем.
Серверний компонент може рендерити клієнтський компонент.
Серверний компонент може передавати серверний вміст клієнтському через children.
Іменовані слоти реалізуються звичайними props типу ReactNode.
Клієнтський компонент не повинен напряму імпортувати серверний компонент.
"use client" варто розміщувати якомога нижче в дереві.
Композиція дає змогу залишити дані та розмітку на сервері, а інтерактивність — у невеликій клієнтській оболонці.
children підходить для основного вмісту, а слоти — для кількох незалежних областей компонента.