У сучасних розподілених веб-додатках із десятками незалежних мікровіджетів та асинхронних джерел даних падіння одного другорядного компонента (наприклад, віджета рекомендацій або біржового графіка) не повинно призводити до повного краху всього інтерфейсу («білого екрана смерті»).
Архітектурна стійкість (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()).