Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
14.09.2026
Обновлено:
14.09.2026

Streaming & Suspense в TypeScript: как строго типизировать fallback и skeleton-компоненты

Тимофей Ищенко

Коротко: Руководство по строгой типизации skeleton-компонентов и Suspense fallback в React и Next.js: синхронизация layout-пропсов, Discriminated Unions и борьба с CLS.

При переходе на потоковый рендеринг (Streaming SSR) и асинхронные серверные компоненты (Server Components) скелетоны перестают быть просто декоративными спиннерами. Они становятся первой версткой, которую видит пользователь и парсит браузер.

Частая проблема при разработке интерфейсов с Suspense — рассинхронизация между реальным компонентом и его заглушкой (fallback). Разработчики меняют размеры карточки, плотность отступов или сетку, забывая обновить скелетон. В результате интерфейс страдает от скачков макета (Cumulative Layout Shift, CLS), а в кодовой базе накапливается хаос из типов data?: T с обилием проверок на undefined.

Строгая система типов TypeScript позволяет синхронизировать геометрию компонентов, исключить невалидные состояния загрузки и сделать fallback предсказуемой частью дизайн-системы.


1. Анатомия Streaming SSR и Suspense: механика работы fallback

React Suspense в связке со Streaming SSR меняет привычный жизненный цикл отрисовки страниц. Вместо ожидания завершения всех сетевых запросов сервер сразу отдает базовую разметку с заглушками, а затем досылает готовый HTML чанками (Chunked Transfer Encoding).

[Сервер] ──── Отправка оболочки + Fallback HTML ───► [Браузер: рендер Skeleton]
   │
   ├──── Асинхронное выполнение запроса данных...
   │
[Сервер] ──── Потоковая отправка чанка с данными ──► [Браузер: подмена Fallback на UI]

Как React стримит HTML чанками

Когда серверный компонент приостанавливает выполнение (suspends), React ищет ближайшую границу <Suspense> выше по дереву и сериализует ее свойство fallback в исходящий HTML-поток.

Позже, когда данные получены и дочерний компонент отрендерен на сервере, React отправляет инлайновый тег <template> с реальным DOM-деревом и минимальный скрипт, заменяющий разметку скелетона на целевой контент.

Если fallback по своим размерам, внешним отступам или CSS Grid-свойствам отличается от готового блока хотя бы на несколько пикселей, происходит сдвиг окружающего контента (Layout Shift).

Границы ответственности: loading.tsx vs гранулярный <Suspense>

В Next.js (App Router) предусмотрено два уровня изоляции загрузки:

  1. Сегментный уровень (loading.tsx): автоматически оборачивает файл page.tsx в Suspense-границу. Компонент в loading.tsx является Server Component по умолчанию и не принимает props. Он предназначен для глобальной структуры маршрута.
  2. Гранулярный уровень (<Suspense fallback={<ComponentSkeleton />}>): управляется вручную внутри компонентов. Позволяет передавать в скелетон параметры контекста, сетки и адаптивности.

2. Типизация Suspense Fallback: от ReactNode к строгим дизайн-системам

В стандартных типах React свойство fallback описано максимально широко:

// Определение из @types/react
interface SuspenseProps {
  children?: ReactNode;
  fallback?: ReactNode;
}

Тип ReactNode принимает строки, числа, null, undefined и произвольный JSX. Такая гибкость удобна для базовой библиотеки, но опасна в масштабируемых проектах. Если передать пустой <div /> вместо структурированного скелетона, компилятор TypeScript не выдаст ошибку, но макет страницы развалится при первом же стриминг-ответе.

Синхронизация геометрии через общие Layout Props

Чтобы скелетон точно повторял габариты основного компонента, свойства отображения (высота, плотность, число колонок, вариант оформления) выделяются в единый базовый интерфейс.

// types/layout.ts
export type ComponentSize = 'sm' | 'md' | 'lg';
export type CardVariant = 'compact' | 'detailed' | 'horizontal';

export interface BaseCardLayoutProps {
  size?: ComponentSize;
  variant?: CardVariant;
  className?: string;
}

Выделение общих свойств позволяет строго связать целевой компонент и скелетон:

import { BaseCardLayoutProps } from './types/layout';

// Пропсы рабочего компонента включают данные и геометрию
export interface UserProfileCardProps extends BaseCardLayoutProps {
  user: {
    id: string;
    fullName: string;
    avatarUrl: string;
    role: string;
  };
}

// Пропсы скелетона используют только геометрию
export type UserProfileCardSkeletonProps = BaseCardLayoutProps;

3. Паттерны строгой типизации Skeleton-компонентов

Рассмотрим три архитектурных подхода к созданию надежных fallback-компонентов в TypeScript.

Паттерн 1: Выделенный Layout Contract с утилитами типов

Если компонент уже спроектирован, его скелетон можно типизировать без ручного дублирования с помощью утилитных типов Pick или Omit:

// components/MetricWidget/MetricWidget.tsx
import { ReactNode } from 'react';

export interface MetricWidgetProps {
  title: string;
  value: number;
  changePercent: number;
  period: 'day' | 'week' | 'month';
  columnsSpan?: 1 | 2 | 3;
}

// Извлекаем только параметры разметки
export type MetricWidgetSkeletonProps = Pick<MetricWidgetProps, 'columnsSpan' | 'period'>;

export function MetricWidgetSkeleton({ columnsSpan = 1, period }: MetricWidgetSkeletonProps) {
  return (
    <div className={`widget-skeleton col-span-${columnsSpan}`}>
      <div className="skeleton-title" />
      <div className="skeleton-value" />
      <span className="skeleton-badge">{period}</span>
    </div>
  );
}

export async function MetricWidget({ title, value, changePercent, columnsSpan = 1, period }: MetricWidgetProps) {
  return (
    <div className={`widget col-span-${columnsSpan}`}>
      <h3>{title}</h3>
      <p className="value">{value}</p>
      <span className={changePercent >= 0 ? 'positive' : 'negative'}>
        {changePercent}% ({period})
      </span>
    </div>
  );
}

Паттерн 2: Discriminated Unions для гибридных компонентов

Когда компонент используется на клиенте без Suspense-границы (например, в формах или модальных окнах с локальным состоянием), объединение типов предотвращает ошибки вида data.user при незавершенной загрузке.

// components/FeedItem/FeedItem.types.ts
interface FeedItemData {
  id: string;
  title: string;
  content: string;
}

interface FeedItemLoadingProps {
  isLoading: true;
  data?: never;
  density?: 'compact' | 'comfortable';
}

interface FeedItemReadyProps {
  isLoading: false;
  data: FeedItemData;
  density?: 'compact' | 'comfortable';
}

export type FeedItemProps = FeedItemLoadingProps | FeedItemReadyProps;

// components/FeedItem/FeedItem.tsx
export function FeedItem(props: FeedItemProps) {
  const { isLoading, density = 'comfortable' } = props;

  if (isLoading) {
    return (
      <div className={`feed-item-skeleton ${density}`}>
        <div className="skeleton-line title-placeholder" />
        <div className="skeleton-line body-placeholder" />
      </div>
    );
  }

  // TypeScript автоматически сужает тип props до FeedItemReadyProps
  const { data } = props;

  return (
    <article className={`feed-item ${density}`}>
      <h2>{data.title}</h2>
      <p>{data.content}</p>
    </article>
  );
}

Использование поля data?: never гарантирует, что разработчик не сможет передать объект данных, одновременно выставив флаг isLoading: true.

Паттерн 3: Compound Components для составных скелетонов

Для сложных интерфейсов (дашборды, аналитические таблицы) связывание скелетона и целевого компонента через статическое свойство упрощает чтение кода и поддержку:

// components/AnalyticsCard/AnalyticsCard.tsx
import { BaseCardLayoutProps } from './types';

interface AnalyticsCardProps extends BaseCardLayoutProps {
  heading: string;
  children: React.ReactNode;
}

export function AnalyticsCard({ heading, children, size = 'md' }: AnalyticsCardProps) {
  return (
    <section className={`analytics-card card-${size}`}>
      <header className="card-header">{heading}</header>
      <div className="card-body">{children}</div>
    </section>
  );
}

// Привязываем скелетон к основному компоненту
AnalyticsCard.Skeleton = function AnalyticsCardSkeleton({ size = 'md' }: BaseCardLayoutProps) {
  return (
    <div className={`analytics-card card-${size} skeleton-mode`} aria-hidden="true">
      <div className="skeleton-line header-placeholder" />
      <div className="skeleton-box content-placeholder" />
    </div>
  );
};

Использование в приложении со стримингом:

import { Suspense } from 'react';
import { AnalyticsCard } from '@/components/AnalyticsCard';
import { RevenueGraph } from '@/components/RevenueGraph';

export default function DashboardPage() {
  return (
    <div className="grid grid-cols-2 gap-4">
      <Suspense fallback={<AnalyticsCard.Skeleton size="lg" />}>
        <AnalyticsCard heading="Выручка" size="lg">
          <RevenueGraph />
        </AnalyticsCard>
      </Suspense>
    </div>
  );
}

4. Предотвращение Layout Shift (CLS) через TypeScript-типизацию

Кумулятивный сдвиг макета (CLS) часто возникает из-за несовпадения структуры списков и адаптивных сеток в fallback-состоянии.

Типизация динамических списков: count и skeleton-массивы

Если компонент рендерит коллекцию элементов, fallback должен резервировать эквивалентное пространство:

// components/ProductGrid/ProductGridSkeleton.tsx
export interface ProductGridSkeletonProps {
  /** Количество заглушек для рендера (рекомендуется передавать точный pageSize) */
  count: number;
  columns?: 2 | 3 | 4;
}

export function ProductGridSkeleton({ count, columns = 3 }: ProductGridSkeletonProps) {
  // Создаем типизированный неизменяемый массив для безопасного рендера списка
  const placeholders = Array.from({ length: count }, (_, index) => `skeleton-item-${index}`);

  return (
    <div className={`grid grid-cols-${columns} gap-6`}>
      {placeholders.map((key) => (
        <div key={key} className="product-card-skeleton">
          <div className="aspect-square bg-neutral-200 animate-pulse rounded-md" />
          <div className="h-4 bg-neutral-200 animate-pulse rounded mt-3 w-3/4" />
          <div className="h-4 bg-neutral-200 animate-pulse rounded mt-2 w-1/2" />
        </div>
      ))}
    </div>
  );
}

Строгая типизация соотношений сторон (Aspect Ratio)

Для медиа-компонентов (изображения, видеоплееры, баннеры) критически важно соблюдать aspect-ratio во время ожидания чанка:

type AspectRatio = '16/9' | '4/3' | '1/1' | '21/9';

export interface MediaBlockSkeletonProps {
  ratio: AspectRatio;
  className?: string;
}

export function MediaBlockSkeleton({ ratio, className }: MediaBlockSkeletonProps) {
  return (
    <div 
      className={`media-skeleton ${className ?? ''}`} 
      style={{ aspectRatio: ratio }}
      role="status"
      aria-label="Загрузка медиаконтента"
    />
  );
}

5. Частые антипаттерны при работе с типами fallback

Необязательные поля data?: T вместо Suspense-архитектуры

Распространенная ошибка — отказ от Suspense в пользу единого компонента с опциональными полями данных:

// ❌ АНТИПАТТЕРН: все поля опциональны, логика перегружена проверками
interface BadArticleProps {
  title?: string;
  content?: string;
  isLoading?: boolean;
}

function BadArticle({ title, content, isLoading }: BadArticleProps) {
  if (isLoading || !title || !content) {
    return <div className="skeleton" />;
  }
  return <article><h1>{title}</h1><p>{content}</p></article>;
}

Этот подход размывает контракт типов: внутри компонента данные всегда могут быть undefined, что требует постоянного использования операторов ?. или !.

//  ПРАВИЛЬНО: разделение ответственности
interface ArticleProps {
  title: string;
  content: string;
}

function Article({ title, content }: ArticleProps) {
  return <article><h1>{title}</h1><p>{content}</p></article>;
}

function ArticleSkeleton() {
  return <div className="skeleton" />;
}

Попытка передать входные параметры в Next.js loading.tsx

Файл loading.tsx на уровне роута работает как глобальная заглушка сегмента:

// ❌ ОШИБКА: loading.tsx не принимает searchParams или params в props
export default function Loading(props: { searchParams: { query: string } }) {
  // props будут пустыми или undefined
  return <div>Загрузка результатов...</div>;
}

Для интерфейсов, зависящих от параметров запроса, используйте гранулярный <Suspense> прямо в page.tsx:

//  ПРАВИЛЬНО: app/search/page.tsx
import { Suspense } from 'react';
import { SearchResults, SearchResultsSkeleton } from '@/components/SearchResults';

interface PageProps {
  searchParams: Promise<{ q?: string }>;
}

export default async function SearchPage({ searchParams }: PageProps) {
  const { q = '' } = await searchParams;

  return (
    <main>
      <h1>Результаты поиска</h1>
      <Suspense key={q} fallback={<SearchResultsSkeleton query={q} />}>
        <SearchResults query={q} />
      </Suspense>
    </main>
  );
}

6. Чек-лист для код-ревью Suspense & Skeleton архитектуры

При проверке pull request с компонентами загрузки ориентируйтесь на следующие критерии:

  1. Единый источник геометрии: размеры, отступы и сетка вынесены в общий интерфейс BaseLayoutProps или утилиту Pick<T, Keys>.
  2. Включен strictNullChecks: свойства целевого компонента не содержат data?: T, если компонент работает внутри Suspense.
  3. Отсутствие Layout Shift: скелетон имеет те же CSS-классы ширины/высоты и внешние отступы, что и родительский контейнер готового компонента.
  4. Доступность (A11y): у скелетона установлены атрибуты aria-hidden="true" или role="status" с поясняющим текстом для экранных дикторов.
  5. Изоляция сегментов: в Next.js loading.tsx используется только для общей структуры страницы, а контекстно-зависимые блоки закрыты гранулярными тегами <Suspense>.

Вопросы и ответы (FAQ)

1. Почему loading.tsx в Next.js не принимает props?

Next.js рендерит loading.tsx на сервере сразу при совпадении сегмента маршрута, до того как завершится чтение динамических параметров или выполнение запросов в page.tsx. Поскольку этот компонент является статической точкой входа Suspense-границы маршрута, он не имеет доступа к контексту страницы. Для параметризованных скелетонов используют явный <Suspense fallback={<CustomSkeleton {...props} />}>.

2. Что предпочтительнее: флаг isLoading или два отдельных компонента (Card и CardSkeleton)?

При использовании React Server Components и Streaming SSR предпочтительнее раздельные компоненты. Это позволяет серверу передавать в Card строго типизированные непустые данные (без null и undefined), а код CardSkeleton изолировать для переиспользования в качестве fallback. Флаг isLoading с Discriminated Unions оправдан в клиентских компонентах с частыми локальными мутациями (например, кнопки сохранения).

3. Как типизация защищает от Cumulative Layout Shift (CLS)?

TypeScript проверяет соответствие layout-пропсов (размер, количество колонок, вариант отображения) между скелетоном и целевым компонентом на этапе компиляции. Если разработчик меняет сетку в компоненте с 3 колонок на 4, компилятор потребует обновить передаваемые свойства и в компоненте-скелетоне.

4. Стоит ли оборачивать в <Suspense> каждый мелкий UI-элемент?

Нет. Слишком мелкие Suspense-границы приводят к эффекту «попкорна» (содержимое страницы хаотично догружается и перерисовывается в разных местах). Рекомендуется группировать связанные асинхронные операции в логические смысловые блоки (виджет аналитики, лента постов, сайдбар пользователя) и оборачивать в Suspense весь блок целиком.

5. Как типизировать Suspense fallback при использовании хука use() в React 19?

Хук use(Promise) разворачивает промис синхронно внутри компонента и выбрасывает suspend-состояние, если промис не завершен. Тип компонента остается обычным (он принимает данные нужного типа), а Suspense-граница снаружи снабжается строго типизированным скелетоном:

import { use } from 'react';

interface User {
  id: string;
  name: string;
}

// Тип данных гарантированно развернут
export function UserDetails({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise);
  return <div>{user.name}</div>;
}

Заключение

Fallback-компоненты при потоковом рендеринге — это полноправные участники UI-архитектуры приложения. Отношение к скелетонам как к второстепенным заглушкам приводит к визуальному шуму, поломке верстки при стриминге и ослаблению типобезопасности.

Выделение общих контрактов геометрии (Layout Props), использование Discriminated Unions и гранулярная расстановка Suspense-границ позволяют создавать надежные, устойчивые к рефакторингу интерфейсы, которые мгновенно загружаются и плавно отображают готовые данные.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также