Пошук уроків, статей та іншого контенту
Розіб’єте великі відповіді на частини й покажете їх поступово за допомогою Streaming та Suspense.
Звичайний серверний рендеринг часто працює так:
Сервер отримує запит.
Завантажує всі дані сторінки.
Формує весь HTML.
Надсилає відповідь браузеру.
Якщо один із запитів повільний, користувач не бачить навіть швидкі частини сторінки, доки не завершиться найповільніша операція.
Streaming змінює цей порядок. Next.js може:
Надіслати загальну структуру сторінки.
Показати швидкі частини одразу.
Для повільних частин показати резервний інтерфейс.
Надсилати готові частини поступово, коли вони завершуються.
У Next.js App Router streaming працює разом із:
серверними компонентами;
Suspense;
файлом loading.tsx;
асинхронними компонентами, які очікують дані.
Suspense визначає межу, всередині якої компонент може тимчасово призупинити рендеринг.
<Suspense fallback={<Loading />}>
<SlowComponent />
</Suspense>Якщо SlowComponent ще очікує дані:
Next.js спочатку відправляє fallback;
решта сторінки може бути відображена;
після завершення асинхронної операції Next.js надсилає готовий результат;
React замінює fallback на справжній компонент.
Важливо, що серверний компонент може бути асинхронним:
async function SlowComponent() {
const data = await loadData();
return <div>{data}</div>;
}Такий компонент не потрібно перетворювати на клієнтський лише через використання await.
Створимо сторінку звіту. Вона міститиме:
заголовок, який відображається одразу;
статистику, яка завантажується швидко;
список замовлень, який завантажується довше;
рекомендації, які завантажуються найдовше.
Структура файлів:
app/
├── page.tsx
└── components/
├── orders.tsx
├── recommendations.tsx
└── statistics.tsx// app/page.tsx
import { Suspense } from "react";
import { Statistics } from "./components/statistics";
import { Orders } from "./components/orders";
import { Recommendations } from "./components/recommendations";
export const dynamic = "force-dynamic";
function SectionSkeleton({ title }: { title: string }) {
return (
<section aria-busy="true">
<h2>{title}</h2>
<p>Завантаження даних...</p>
</section>
);
}
export default function DashboardPage() {
return (
<main>
<h1>Звіт магазину</h1>
<p>Частини сторінки завантажуються незалежно одна від одної.</p>
<Suspense fallback={<SectionSkeleton title="Статистика" />}>
<Statistics />
</Suspense>
<Suspense fallback={<SectionSkeleton title="Останні замовлення" />}>
<Orders />
</Suspense>
<Suspense fallback={<SectionSkeleton title="Рекомендації" />}>
<Recommendations />
</Suspense>
</main>
);
}page.tsx не має бути позначений директивою "use client". Це серверний компонент, який створює межі Suspense.
dynamic = "force-dynamic" потрібен у цьому прикладі, щоб сторінка генерувалася під час кожного запиту, а штучні затримки можна було побачити під час запуску.
// app/components/statistics.tsx
async function loadStatistics() {
// Імітація швидкого запиту до бази даних
await new Promise((resolve) => setTimeout(resolve, 700));
return {
revenue: 12840,
orders: 342,
};
}
export async function Statistics() {
const statistics = await loadStatistics();
return (
<section>
<h2>Статистика</h2>
<ul>
<li>Дохід: {statistics.revenue} грн</li>
<li>Замовлення: {statistics.orders}</li>
</ul>
</section>
);
}// app/components/orders.tsx
async function loadOrders() {
// Імітація повільнішого запиту до бази даних
await new Promise((resolve) => setTimeout(resolve, 1800));
return [
{ id: "A-1001", customer: "Олена", total: 1250 },
{ id: "A-1002", customer: "Андрій", total: 890 },
{ id: "A-1003", customer: "Марія", total: 2140 },
];
}
export async function Orders() {
const orders = await loadOrders();
return (
<section>
<h2>Останні замовлення</h2>
<ul>
{orders.map((order) => (
<li key={order.id}>
{order.id}: {order.customer}, {order.total} грн
</li>
))}
</ul>
</section>
);
}// app/components/recommendations.tsx
async function loadRecommendations() {
// Імітація запиту до зовнішнього сервісу рекомендацій
await new Promise((resolve) => setTimeout(resolve, 3200));
return [
"Збільшити запас популярних товарів",
"Додати знижку для повторних покупців",
];
}
export async function Recommendations() {
const recommendations = await loadRecommendations();
return (
<section>
<h2>Рекомендації</h2>
<ul>
{recommendations.map((recommendation) => (
<li key={recommendation}>{recommendation}</li>
))}
</ul>
</section>
);
}Під час відкриття сторінки користувач побачить приблизно таку послідовність:
Заголовок сторінки та три резервні блоки.
Приблизно через 700 мс — статистику.
Приблизно через 1800 мс — список замовлень.
Приблизно через 3200 мс — рекомендації.
Повільні компоненти не блокують один одного, оскільки кожен має власну межу Suspense.
Межа Suspense визначає мінімальну частину інтерфейсу, яка може очікувати дані.
<Suspense fallback={<PageSkeleton />}>
<Statistics />
<Orders />
<Recommendations />
</Suspense>У цьому випадку fallback залишатиметься, доки не завершаться всі три компоненти. Користувач отримає результат одним великим блоком.
<Suspense fallback={<StatisticsSkeleton />}>
<Statistics />
</Suspense>
<Suspense fallback={<OrdersSkeleton />}>
<Orders />
</Suspense>
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations />
</Suspense>Тепер кожен компонент може з’явитися незалежно. Це зазвичай краще для сторінок, де частини мають різну швидкість завантаження.
Межу варто створювати навколо логічно завершеного блоку:
картки статистики;
таблиці;
списку повідомлень;
панелі рекомендацій;
окремого віджета.
Не варто створювати Suspense навколо кожного маленького текстового елемента. Надто дрібні межі можуть ускладнити інтерфейс і створити багато короткочасних станів завантаження.
Замість штучної затримки серверний компонент зазвичай звертається до бази даних або зовнішнього сервісу.
Наприклад:
// app/components/orders.tsx
type Order = {
id: string;
customer: string;
total: number;
};
async function loadOrders(): Promise<Order[]> {
const response = await fetch("https://api.example.com/orders", {
cache: "no-store",
});
if (!response.ok) {
throw new Error("Не вдалося завантажити замовлення");
}
return response.json();
}
export async function Orders() {
const orders = await loadOrders();
return (
<section>
<h2>Останні замовлення</h2>
<ul>
{orders.map((order) => (
<li key={order.id}>
{order.id}: {order.customer}, {order.total} грн
</li>
))}
</ul>
</section>
);
}cache: "no-store" вказує, що дані потрібно отримувати заново для кожного запиту. Це доречно для даних, які часто змінюються.
У реальному застосунку обробка помилки також має бути передбачена. Якщо асинхронний компонент завершується помилкою, її повинен обробити найближчий error boundary.
Next.js підтримує спеціальний файл loading.tsx у сегменті маршруту:
// app/loading.tsx
export default function Loading() {
return (
<main>
<h1>Завантаження сторінки...</h1>
</main>
);
}Next.js автоматично використовує цей компонент як стан завантаження маршруту. Фактично він створює межу завантаження навколо відповідного сегмента сторінки.
loading.tsx підходить для загального стану завантаження маршруту. Власні межі Suspense потрібні, коли різні частини сторінки мають завантажуватися окремо.
Наприклад:
loading.tsx може показати загальний каркас сторінки;
внутрішні Suspense можуть окремо показувати таблицю, графік і рекомендації.
Streaming не означає, що весь код має виконуватися в браузері. Навпаки, у Next.js асинхронні компоненти для отримання даних зазвичай залишаються серверними.
Клієнтський компонент можна використовувати всередині серверної сторінки:
// app/components/filter.tsx
"use client";
import { useState } from "react";
export function Filter() {
const [query, setQuery] = useState("");
return (
<label>
Пошук
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</label>
);
}Але отримання даних і очікування результату краще залишати в серверному компоненті, якщо інтерактивність браузера для цього не потрібна.
// app/page.tsx
import { Suspense } from "react";
import { Filter } from "./components/filter";
import { Orders } from "./components/orders";
export default function Page() {
return (
<main>
<Filter />
<Suspense fallback={<p>Завантаження замовлень...</p>}>
<Orders />
</Suspense>
</main>
);
}Filter працює на клієнті, а Orders завантажує дані на сервері. Suspense може оточувати серверний компонент, навіть якщо поруч розташований клієнтський.
fallback має бути схожим за розміром і структурою на майбутній вміст. Це зменшує стрибки layout під час заміни завантаження реальними даними.
Недостатньо:
<Suspense fallback={<p>Завантаження...</p>}>
<Orders />
</Suspense>Для таблиці або складної картки краще створити структурований skeleton:
function OrdersSkeleton() {
return (
<section aria-busy="true" aria-live="polite">
<h2>Останні замовлення</h2>
<ul>
<li>Завантаження замовлення...</li>
<li>Завантаження замовлення...</li>
<li>Завантаження замовлення...</li>
</ul>
</section>
);
}Потім:
<Suspense fallback={<OrdersSkeleton />}>
<Orders />
</Suspense>Атрибут aria-busy="true" повідомляє допоміжним технологіям, що область ще завантажується.
Під час streaming браузер отримує не обов’язково завершений HTML одним блоком. Next.js може надсилати:
початкову HTML-структуру;
резервний вміст для незавершених меж;
дані React Server Components;
інструкції для заміни резервного вмісту готовим результатом.
Для користувача це виглядає як поступове заповнення сторінки. Компоненти не обов’язково з’являються в тому порядку, у якому вони записані у файлі. Вони з’являються тоді, коли завершили свої асинхронні операції та можуть бути відправлені клієнту.
Такий код блокує всю сторінку:
export default async function Page() {
const statistics = await loadStatistics();
const orders = await loadOrders();
const recommendations = await loadRecommendations();
return (
<main>
<Statistics data={statistics} />
<Orders data={orders} />
<Recommendations data={recommendations} />
</main>
);
}Навіть якщо статистика готова швидко, вона не буде показана, доки не завершаться замовлення та рекомендації.
Якщо частини не залежать одна від одної, їх краще розділити межами Suspense.
<Suspense fallback={<FullPageSkeleton />}>
<Statistics />
<Orders />
<Recommendations />
</Suspense>Це допустимо, але втрачає перевагу поступового відображення. Повільний компонент затримуватиме всі компоненти всередині цієї межі.
use client без потребиЯкщо додати "use client" до компонента, який лише отримує дані на сервері, можна:
збільшити JavaScript-бандл;
перенести завантаження даних у браузер;
втратити частину переваг серверного рендерингу.
Асинхронний компонент із серверним запитом не повинен ставати клієнтським лише для того, щоб використовувати await.
Suspense вимагає fallback:
<Suspense>
<Orders />
</Suspense>Потрібно явно описати, що побачить користувач під час очікування:
<Suspense fallback={<OrdersSkeleton />}>
<Orders />
</Suspense>Якщо обгорнути кожен рядок і кнопку в окремий Suspense, інтерфейс може постійно перемальовуватися дрібними фрагментами. Межа повинна відповідати змістовному блоку інтерфейсу.
Streaming не означає завантаження наступної сторінки за кнопкою або нескінченний список. Він описує спосіб доставки поточного результату: готові частини інтерфейсу надсилаються поступово, не чекаючи завершення всіх операцій.
Streaming дає змогу надсилати результат сторінки частинами.
Suspense визначає, які частини можуть очікувати дані незалежно.
fallback відображається, доки асинхронний серверний компонент не завершить роботу.
Окремі межі Suspense дають змогу показувати швидкі блоки раніше за повільні.
loading.tsx призначений для загального стану завантаження маршруту.
Отримання даних зазвичай залишається в серверних компонентах.
Межі потрібно створювати навколо логічних блоків, а не навколо кожного елемента.