Урок 06

Streaming и Suspense

Streaming — отправка HTML по частям, по мере готовности.

Проблема

Без streaming страница ждёт все данные, потом отправляет HTML. Если один запрос медленный — пользователь видит белый экран.

Решение: Suspense

import { Suspense } from 'react';

export default function Page() {
    return (
        <>
            <Header /> {/* сразу */}
            <Suspense fallback={<Skeleton />}>
                <SlowComponent /> {/* позже */}
            </Suspense>
        </>
    );
}

async function SlowComponent() {
    const data = await fetchSlowData();
    return <div>{data}</div>;
}

Пользователь видит Header и Skeleton сразу, SlowComponent — по мере готовности.

Несколько Suspense

<>
    <Header />
    <Suspense fallback={<PostsSkeleton />}>
        <Posts />
    </Suspense>
    <Suspense fallback={<CommentsSkeleton />}>
        <Comments />
    </Suspense>
    <Suspense fallback={<SidebarSkeleton />}>
        <Sidebar />
    </Suspense>
</>

Каждый блок грузится независимо.

loading.tsx

Автоматический Suspense для страницы:

// app/posts/loading.tsx
export default function Loading() {
    return <PostsSkeleton />;
}

Показывается, пока страница грузится.

Вложенные Suspense

<Suspense fallback={<PageSkeleton />}>
    <Header />
    <Suspense fallback={<ListSkeleton />}>
        <List />
    </Suspense>
    <Suspense fallback={<SidebarSkeleton />}>
        <Sidebar />
    </Suspense>
</Suspense>

Внешний показывается первым, внутренние — по мере готовности.

Когда использовать

  • Медленные запросы к БД
  • Внешние API
  • Тяжёлые вычисления
  • Персонализированные блоки

Что не нужно оборачивать

  • Быстрые компоненты
  • Статические части
  • Всё, что и так быстро

Скелетоны

function PostsSkeleton() {
    return (
        <div className="skeleton">
            {[...Array(5)].map((_, i) => (
                <div key={i} className="skeleton-item" />
            ))}
        </div>
    );
}

Streaming в API

// app/api/stream/route.ts
export async function GET() {
    const encoder = new TextEncoder();

    const stream = new ReadableStream({
        async start(controller) {
            for (let i = 0; i < 10; i++) {
                controller.enqueue(encoder.encode(`chunk ${i}\n`));
                await new Promise((r) => setTimeout(r, 100));
            }
            controller.close();
        },
    });

    return new Response(stream, {
        headers: { 'Content-Type': 'text/plain' },
    });
}

AI-стриминг

Vercel AI SDK стримит ответы модели:

import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';

export async function POST(req: Request) {
    const { prompt } = await req.json();

    const result = streamText({
        model: openai('gpt-4'),
        prompt,
    });

    return result.toDataStreamResponse();
}

Клиент получает токены по мере генерации.

Suspense и use

import { use } from 'react';

function Comments({ commentsPromise }: { commentsPromise: Promise<Comment[]> }) {
    const comments = use(commentsPromise); // разворачивает промис
    return <ul>{comments.map((c) => <li key={c.id}>{c.text}</li>)}</ul>;
}

// В родителе
const commentsPromise = getComments();
<Suspense fallback={<Skeleton />}>
    <Comments commentsPromise={commentsPromise} />
</Suspense>

use — новый хук React для промисов в клиентских компонентах.

Итоги

  • Streaming отправляет HTML по частям
  • Suspense оборачивает медленные блоки
  • loading.tsx — автоматический Suspense
  • Скелетоны для UX
  • AI-ответы стримятся через AI SDK
  • use для промисов в клиентских