Пошук уроків, статей та іншого контенту
Передавайте HTML і дані частинами за допомогою Suspense та streaming для швидшого відображення сторінок.
Streaming у Next.js — це передавання HTML-відповіді частинами, щойно окремі частини сторінки стають готовими.
Без streaming сервер зазвичай чекає на завершення всіх асинхронних операцій:
отримує дані для всієї сторінки;
формує повний HTML;
надсилає відповідь браузеру.
Якщо один запит повільний, користувач довго бачить порожню сторінку.
За streaming сервер може:
одразу надіслати готовий каркас сторінки;
показати fallback для повільної частини;
додати готовий контент пізніше, коли дані завантажаться.
У Next.js App Router streaming працює разом із:
React Server Components;
<Suspense>;
асинхронними Server Components;
спеціальним файлом loading.tsx.
Suspense визначає межу, всередині якої може бути компонент, що ще не готовий до відображення.
<Suspense fallback={<Loading />}>
<SlowComponent />
</Suspense>Поки SlowComponent очікує дані, користувач бачить fallback. Коли компонент завершить виконання, Next.js передасть його результат браузеру, а React замінить fallback готовим контентом.
Важливо, що Suspense не прискорює сам запит. Він дозволяє не блокувати всю сторінку через один повільний запит.
Server Component може бути асинхронним. Він виконується на сервері та може очікувати дані безпосередньо в тілі компонента.
Розглянемо сторінку каталогу:
// app/page.tsx
import { Suspense } from "react";
export const dynamic = "force-dynamic";
type Product = {
id: number;
name: string;
price: number;
};
function delay(milliseconds: number) {
return new Promise((resolve) => {
setTimeout(resolve, milliseconds);
});
}
async function getProducts(): Promise<Product[]> {
await delay(3000);
return [
{ id: 1, name: "Механічна клавіатура", price: 3200 },
{ id: 2, name: "USB-мікрофон", price: 4100 },
{ id: 3, name: "Вебкамера", price: 2800 },
];
}
function ProductsSkeleton() {
return (
<div aria-busy="true">
<p>Завантаження товарів...</p>
<ul>
<li>Підготовка товару...</li>
<li>Підготовка товару...</li>
<li>Підготовка товару...</li>
</ul>
</div>
);
}
async function ProductList() {
const products = await getProducts();
return (
<ul>
{products.map((product) => (
<li key={product.id}>
{product.name} — {product.price} грн
</li>
))}
</ul>
);
}
export default function HomePage() {
return (
<main>
<h1>Каталог</h1>
<p>Цей заголовок відображається одразу.</p>
<Suspense fallback={<ProductsSkeleton />}>
<ProductList />
</Suspense>
</main>
);
}У цьому прикладі:
HomePage є Server Component;
ProductList очікує дані протягом трьох секунд;
заголовок і текст сторінки можуть бути відправлені одразу;
замість списку спочатку відображається ProductsSkeleton;
після завершення getProducts() список замінює skeleton.
dynamic = "force-dynamic" змушує Next.js виконувати сторінку для кожного запиту. У реальному застосунку дані зазвичай надходитимуть із бази даних або зовнішнього API, а не з функції delay.
Якщо сторінка має кілька незалежних частин, кожну повільну частину можна обгорнути у власний Suspense.
// app/dashboard/page.tsx
import { Suspense } from "react";
export const dynamic = "force-dynamic";
function delay(milliseconds: number) {
return new Promise((resolve) => {
setTimeout(resolve, milliseconds);
});
}
async function Revenue() {
await delay(1000);
return (
<section>
<h2>Дохід</h2>
<p>125 400 грн</p>
</section>
);
}
async function Orders() {
await delay(3000);
return (
<section>
<h2>Замовлення</h2>
<p>248 замовлень</p>
</section>
);
}
function SectionSkeleton({ title }: { title: string }) {
return (
<section aria-busy="true">
<h2>{title}</h2>
<p>Завантаження...</p>
</section>
);
}
export default function DashboardPage() {
return (
<main>
<h1>Панель керування</h1>
<Suspense fallback={<SectionSkeleton title="Дохід" />}>
<Revenue />
</Suspense>
<Suspense fallback={<SectionSkeleton title="Замовлення" />}>
<Orders />
</Suspense>
</main>
);
}У цьому випадку:
заголовок відображається одразу;
блок «Дохід» стає готовим приблизно через одну секунду;
блок «Замовлення» стає готовим приблизно через три секунди;
один блок не затримує інший.
Це краще за одну велику межу:
<Suspense fallback={<PageSkeleton />}>
<Revenue />
<Orders />
</Suspense>Одна межа змусила б обидва блоки чекати завершення найповільнішого компонента.
Streaming показує готові частини, але асинхронні операції всередині одного компонента все одно можуть виконуватися послідовно.
Поганий варіант:
async function DashboardData() {
const revenue = await getRevenue();
const orders = await getOrders();
return (
<>
<RevenueView value={revenue} />
<OrdersView value={orders} />
</>
);
}Якщо кожен запит триває дві секунди, загальний час очікування може становити приблизно чотири секунди.
Коли обидва запити не залежать один від одного, запускайте їх паралельно:
async function DashboardData() {
const [revenue, orders] = await Promise.all([
getRevenue(),
getOrders(),
]);
return (
<>
<RevenueView value={revenue} />
<OrdersView value={orders} />
</>
);
}У такому разі загальний час буде близьким до тривалості найдовшого запиту, а не до суми тривалостей.
Водночас Promise.all() не створює окремі streaming-частини. Усі результати DashboardData з’являться після завершення обох запитів. Для незалежного відображення використовуйте окремі компоненти та окремі межі Suspense.
Next.js дозволяє створити спеціальний файл loading.tsx у сегменті маршруту:
// app/products/loading.tsx
export default function Loading() {
return (
<main aria-busy="true">
<h1>Каталог</h1>
<p>Завантаження сторінки...</p>
</main>
);
}Файл loading.tsx автоматично використовується як fallback для відповідного маршруту app/products.
Структура може мати такий вигляд:
app/
├── products/
│ ├── page.tsx
│ └── loading.tsxЦе зручно для загального стану завантаження маршруту. Внутрішні Suspense-межі потрібні, коли треба керувати окремими частинами сторінки незалежно.
Наприклад:
loading.tsx показує загальний каркас маршруту;
Suspense для списку показує skeleton списку;
Suspense для рекомендацій показує окремий skeleton рекомендацій.
Межу варто розміщувати там, де користувач може отримати корисний контент незалежно від повільної частини.
Добре:
<header>
<h1>Профіль користувача</h1>
</header>
<Suspense fallback={<ProfileSkeleton />}>
<Profile />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<Activity />
</Suspense>Заголовок і кожен блок мають власний життєвий цикл завантаження.
Менш корисно:
<Suspense fallback={<FullPageSkeleton />}>
<Header />
<Profile />
<Activity />
</Suspense>Якщо Activity повільний, fallback приховає також уже готові Header і Profile.
Межа має бути достатньо великою, щоб fallback не створював зайвого візуального шуму, але достатньо малою, щоб повільний запит не блокував непов’язані частини.
Асинхронні Server Components виконуються на сервері. Їхній результат передається браузеру як HTML і службові дані React Server Components.
Це означає, що для компонента, який лише відображає отримані на сервері дані, не обов’язково завантажувати JavaScript у браузер.
"use client" потрібен компонентам, які використовують:
обробники подій;
стан через useState;
ефекти через useEffect;
API браузера.
Suspense може використовуватися і з Client Components, але сам по собі "use client" не робить компонент Server Component і не замінює серверний streaming.
Під час streaming браузер отримує відповідь поступово:
початковий HTML із доступною частиною сторінки;
fallback для компонентів, які ще очікують;
додаткові дані та розмітку для завершених меж;
оновлення DOM, які виконує React.
Користувач може почати взаємодіяти з уже готовою частиною сторінки раніше, ніж завершаться всі запити.
Фактичний результат залежить також від:
часу отримання даних;
кешування;
конфігурації сервера;
reverse proxy;
поведінки CDN;
режиму розробки або production.
У режимі розробки затримки та спосіб відображення streaming можуть відрізнятися від production. Перевіряйте поведінку після production-збірки.
Suspense призначений для стану очікування, але не для обробки помилок. Якщо асинхронний Server Component завершується винятком, потрібен error boundary маршруту, наприклад error.tsx.
// app/products/error.tsx
"use client";
export default function Error({
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<main>
<h1>Не вдалося завантажити каталог</h1>
<button type="button" onClick={() => reset()}>
Спробувати ще раз
</button>
</main>
);
}loading.tsx і Suspense відповідають за очікування, а error.tsx — за помилки під час відтворення маршруту або його частини.
Якщо обгорнути всю сторінку однією межею, повільний компонент блокуватиме відображення всіх інших.
Розділяйте незалежні області на кілька меж.
Якщо skeleton значно менший за готовий контент, після завершення streaming сторінка може різко змінити висоту.
Fallback має приблизно повторювати структуру та розміри майбутнього контенту.
Кілька await один за одним створюють зайву затримку, якщо запити не залежать один від одного.
Використовуйте Promise.all() для спільного результату або окремі Suspense-межі для незалежного відображення.
Якщо батьківський компонент очікує всі дані перед тим, як відрендерити дочірні компоненти, дочірні Suspense-межі можуть втратити користь.
Краще розміщувати отримання даних у тому Server Component, який безпосередньо відповідає за конкретну частину інтерфейсу.
Suspense без асинхронної роботиЯкщо всередині межі немає компонента або операції, які можуть призупинити відображення, fallback не буде показаний.
Suspense не є звичайним індикатором завантаження, який вмикається вручну.
Dev-сервер може обробляти та передавати відповідь інакше, ніж production-середовище.
Перевіряйте streaming за допомогою production-команд:
npm run build
npm run startStreaming передає HTML і дані частинами, не чекаючи завершення всієї сторінки.
<Suspense> задає межу для частини інтерфейсу, яка може завантажуватися асинхронно.
fallback відображається, поки Server Component очікує дані.
Кілька незалежних Suspense-меж дозволяють частинам сторінки з’являтися у власний момент.
loading.tsx підходить для загального стану завантаження маршруту.
Promise.all() прискорює незалежні запити, але не створює окремі streaming-частини.
Межі потрібно розміщувати навколо логічних областей, щоб повільний запит не блокував готовий контент.
Для помилок потрібен error boundary, наприклад error.tsx.