Коротко: Руководство по строгой типизации 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) предусмотрено два уровня изоляции загрузки:
- Сегментный уровень (
loading.tsx): автоматически оборачивает файлpage.tsxв Suspense-границу. Компонент вloading.tsxявляется Server Component по умолчанию и не принимает props. Он предназначен для глобальной структуры маршрута. - Гранулярный уровень (
<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 с компонентами загрузки ориентируйтесь на следующие критерии:
- Единый источник геометрии: размеры, отступы и сетка вынесены в общий интерфейс
BaseLayoutPropsили утилитуPick<T, Keys>. - Включен
strictNullChecks: свойства целевого компонента не содержатdata?: T, если компонент работает внутри Suspense. - Отсутствие Layout Shift: скелетон имеет те же CSS-классы ширины/высоты и внешние отступы, что и родительский контейнер готового компонента.
- Доступность (A11y): у скелетона установлены атрибуты
aria-hidden="true"илиrole="status"с поясняющим текстом для экранных дикторов. - Изоляция сегментов: в 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-границ позволяют создавать надежные, устойчивые к рефакторингу интерфейсы, которые мгновенно загружаются и плавно отображают готовые данные.




.svg.webp)




