В современных распределенных веб-приложениях с десятками независимых микровиджетов и асинхронных источников данных падение одного второстепенного компонента (например, виджета рекомендаций или биржевого графика) не должно приводить к полному краху всего интерфейса («белому экрану смерти»).
Архитектурная устойчивость (Resilience) в React строится на связке двух декларативных защитных механизмов:
Без гранулярных защитных границ приложение сталкивается с тремя критическими архитектурными дефектами:
Suspense заставляют вложенные компоненты ждать данные последовательно один за другим, удваивая задержку загрузки страницы и вызывая раздражающее мерцание интерфейса.React трактует рендеринг как детерминированную функцию. Если во время рендеринга компонента выбрасывается исключение, React размонтирует дерево вверх по иерархии до ближайшего обработчика ошибок. Если Error Boundary не установлен, размонтируется все приложение.
Подобно тому, как классический блок try...catch перехватывает синхронные ошибки в императивном коде, Error Boundary выступает декларативным блоком try...catch для виртуального DOM-дерева.
Suspense действует аналогично: когда компонент обращается к асинхронному ресурсу через хук use(Promise) в React 19, он приостанавливает текущий рендеринг («выбрасывает промис» в рантайм React), заставляя ближайшую границу Suspense переключиться на рендеринг loadingFallback.
Компонент запрашивает ресурс через use(Promise); граница Suspense активирует точный Skeleton-экран.
При возникновении исключения Error Boundary перехватывает сбой, предотвращая белый экран.
Метод componentDidCatch отправляет стек вызовов и метаданные пользователя в систему мониторинга.
Пользователь нажимает 'Повторить' или меняется URL (resetKeys); сбрасывается стейт и кэш данных.
Какие из перечисленных типов ошибок НЕ перехватываются компонентом Error Boundary автоматически?
Корпоративные дашборды с десятками графиков, финансовых сводок и таблиц. Если API одной из метрик возвращает статус 500 или невалидный JSON, только этот конкретный блок показывает сообщение об ошибке и кнопку «Повторить», а остальная часть экрана остается интерактивной.
Использование границ Suspense со специализированными Skeleton-компонентами строго фиксированных габаритов. Это удерживает показатель метрики CLS близким к 0, предотвращая смещение соседних блоков страницы при загрузке данных.
В архитектуре React Server Components сбой рендеринга на сервере внутри Error Boundary не прерывает генерацию всего HTML-документа: сервер отправляет готовый каркас страницы, а проблемная секция безопасно переходит в клиентский fallback.
Жизненный цикл перехвата сбоя в React строго разделен между синхронным переключением состояния и асинхронным аудитом:
Во время рендеринга компонента происходит TypeError (например, чтение свойства из undefined) или сбой в промисе use().
React вызывает static getDerivedStateFromError(error). Возвращается обновленный стейт { hasError: true, error }.
В фазе коммита вызывается componentDidCatch(error, errorInfo), отправляющий трассировку стека в Sentry/Datadog.
React рендерит переданный компонент fallbackRender с кнопкой повтора действия (resetErrorBoundary).
Каким образом массив resetKeys предотвращает застревание пользователя на экране ошибки при клиентской навигации?
Строгая реализация классового компонента границы ошибок в TypeScript без использования небезопасного типа any:
'use client';
import * as React from 'react';
export interface FallbackProps {
error: Error;
resetErrorBoundary: () => void;
}
export interface ErrorBoundaryProps {
/** Запасной компонент, принимающий объект ошибки и колбэк сброса */
fallbackRender?: (props: FallbackProps) => React.ReactNode;
/** Запасной ReactNode (если не требуется логика сброса) */
fallback?: React.ReactNode;
/** Коллбэк для отправки в систему мониторинга */
onError?: (error: Error, info: React.ErrorInfo) => void;
/** Массив зависимостей (например, ID страницы или query-параметры), при смене которых ошибка сбрасывается */
resetKeys?: Array<unknown>;
/** Дочерние элементы */
children: React.ReactNode;
}
interface ErrorBoundaryState {
error: Error | null;
}
const initialState: ErrorBoundaryState = { error: null };
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
public override state: ErrorBoundaryState = initialState;
public static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
const normalizedError = error instanceof Error ? error : new Error(String(error));
return { error: normalizedError };
}
public override componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
// Вызов пользовательского обработчика телеметрии (Sentry / Datadog)
this.props.onError?.(error, errorInfo);
}
public override componentDidUpdate(prevProps: ErrorBoundaryProps): void {
const { error } = this.state;
const { resetKeys } = this.props;
// Автоматический сброс ошибки при смене ключей resetKeys
if (error !== null && resetKeys && prevProps.resetKeys) {
const hasKeysChanged = resetKeys.some(
(key, index) => !Object.is(key, prevProps.resetKeys?.[index])
);
if (hasKeysChanged) {
this.reset();
}
}
}
public reset = (): void => {
this.setState(initialState);
};
public override render(): React.ReactNode {
const { error } = this.state;
const { fallbackRender, fallback, children } = this.props;
if (error !== null) {
if (typeof fallbackRender === 'function') {
return fallbackRender({
error,
resetErrorBoundary: this.reset,
});
}
if (fallback !== undefined) {
return fallback;
}
// Дефолтный запасной интерфейс
return (
<div role="alert" className="p-4 rounded-xl bg-red-950/30 border border-red-900/50 text-red-200 text-sm">
<p className="font-semibold mb-1">Произошел сбой компонента</p>
<p className="text-xs text-red-400 font-mono mb-3">{error.message}</p>
<button
type="button"
onClick={this.reset}
className="px-3 py-1.5 bg-red-800/60 hover:bg-red-700/60 rounded text-xs font-medium transition-colors cursor-pointer"
>
Повторить попытку
</button>
</div>
);
}
return children;
}
}Объединим ErrorBoundary и Suspense в единый защитный примитив для асинхронных операций:
'use client';
import * as React from 'react';
import { ErrorBoundary, FallbackProps } from './ErrorBoundary';
export interface AsyncBoundaryProps {
/** Запасной компонент при ошибке */
errorFallback?: (props: FallbackProps) => React.ReactNode;
/** Запасной интерфейс ожидания (Скелетон) */
loadingFallback: React.ReactNode;
/** Ключи автоматического сброса ошибки */
resetKeys?: Array<unknown>;
/** Коллбэк логирования */
onError?: (error: Error, info: React.ErrorInfo) => void;
children: React.ReactNode;
}
/**
* AsyncBoundary: Единая граница изоляции асинхронного состояния и отказоустойчивости
*/
export function AsyncBoundary({
errorFallback,
loadingFallback,
resetKeys,
onError,
children,
}: AsyncBoundaryProps) {
return (
<ErrorBoundary
fallbackRender={errorFallback}
resetKeys={resetKeys}
onError={onError}
>
<React.Suspense fallback={loadingFallback}>
{children}
</React.Suspense>
</ErrorBoundary>
);
}'use client';
import * as React from 'react';
import { AsyncBoundary } from '@/components/resilience/AsyncBoundary';
// Скелетон, строго повторяющий габариты финального виджета для предотвращения CLS
function MetricsSkeleton() {
return (
<div className="p-5 rounded-2xl bg-bg-surface border border-border animate-pulse space-y-4">
<div className="h-4 w-32 bg-bg-elevated rounded-md" />
<div className="h-8 w-24 bg-bg-elevated rounded-md" />
<div className="h-2 w-full bg-bg-elevated rounded-full" />
</div>
);
}
// Запасной UI ошибки с кнопкой перезапроса
function MetricsErrorFallback({ error, resetErrorBoundary }: { error: Error; resetErrorBoundary: () => void }) {
return (
<div className="p-5 rounded-2xl bg-bg-surface border border-red-900/40 space-y-3">
<div className="flex items-center gap-2 text-red-400">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span className="text-sm font-semibold">Ошибка загрузки метрик</span>
</div>
<p className="text-xs text-text-muted">{error.message}</p>
<button
type="button"
onClick={resetErrorBoundary}
className="text-xs px-3 py-1.5 bg-bg-elevated text-text-primary rounded-lg hover:bg-border transition-colors cursor-pointer"
>
Перезапросить данные
</button>
</div>
);
}
// Внутренний компонент с симуляцией получения данных
function MetricsContent({ serverId }: { serverId: string }) {
if (serverId === 'failed-node') {
throw new Error('Узел failed-node недоступен (503 Service Unavailable)');
}
return (
<div className="p-5 rounded-2xl bg-bg-surface border border-border space-y-2">
<span className="text-xs font-medium text-text-secondary uppercase tracking-wider">
Нагрузка CPU ({serverId})
</span>
<div className="text-3xl font-bold text-text-primary">42.8%</div>
<div className="w-full bg-bg-elevated rounded-full h-2 overflow-hidden">
<div className="bg-accent-violet h-full rounded-full" style={{ width: '42.8%' }} />
</div>
</div>
);
}
export function ServerMetricsWidget({ serverId }: { serverId: string }) {
return (
<AsyncBoundary
loadingFallback={<MetricsSkeleton />}
errorFallback={MetricsErrorFallback}
resetKeys={[serverId]} // При смене serverId ошибка автоматически сбрасывается!
onError={(err) => {
console.error(`[Telemetry]: Сбой в виджете метрик для сервера ${serverId}:`, err);
}}
>
<MetricsContent serverId={serverId} />
</AsyncBoundary>
);
}Error Boundary перехватывает ошибки только в фазе рендеринга, в конструкторах и методах жизненного цикла. Ошибки внутри setTimeout, слушателей событий (onClick) или внутри асинхронных функций useEffect границей не перехватываются.
// ❌ АНТИПАТТЕРН: Ошибка в обработчике не будет поймана ErrorBoundary
function BadComponent() {
const handleClick = async () => {
// Эта ошибка НЕ попадет в Error Boundary! Произойдет Unhandled Promise Rejection
throw new Error('Крах в onClick');
};
return <button onClick={handleClick}>Сбой</button>;
}
// ✅ ПРАВИЛЬНО: Использование хука useErrorBoundary из react-error-boundary для проброса в рендер
import { useErrorBoundary } from 'react-error-boundary';
function GoodComponent() {
const { showBoundary } = useErrorBoundary();
const handleClick = async () => {
try {
await api.doWork();
} catch (err) {
showBoundary(err); // Пробрасывает ошибку в ближайший Error Boundary во время следующего рендера
}
};
return <button onClick={handleClick}>Безопасный вызов</button>;
}Если родительский компонент ждет свои данные в Suspense, а внутри рендерит ребенка, который ждет свои данные в отдельном Suspense, данные начинают запрашиваться последовательно (водопад), удваивая задержку загрузки интерфейса. Параллелизуйте запросы данных (Promise.all) или поднимайте границы ожидания выше по дереву.
Использование простого крутящегося спиннера размером 20x20 пикселей для блока, который после загрузки займет 400x300 пикселей, приводит к сильнейшему скачку разметки (CLS). Запасной UI в Suspense обязан по своим габаритам и отступам в точности повторять результирующий компонент.
Если компонент упал из-за ошибки в библиотеке управления состоянием (например, TanStack Query), простое нажатие кнопки сброса в Error Boundary вызовет ререндер компонента, который немедленно прочитает ту же самую закэшированную ошибку и упадет снова. В функции сброса необходимо явно инвалидировать кэш запроса (queryClient.resetQueries()).