Error Boundaries в React: где ставить и как не превратить в общий catch
Error Boundary — один из тех механизмов, про который все знают, но почти никто не использует осознанно. Чаще всего я вижу либо «в проекте нет ни одной границы, всё падает белым экраном», либо «один глобальный Error Boundary в корне, ловит всё подряд и ничего полезного не делает».
Расскажу, как я расставляю Error Boundary в реальных проектах, что из них надо вытаскивать наружу, и почему один глобальный обработчик — это не решение.
Что Error Boundary ловит, а что нет
Это первое, что нужно понять. Error Boundary в React ловит ошибки при рендере, в lifecycle-методах и в конструкторах дочерних компонентов. Не ловит:
- Ошибки в обработчиках событий (onClick, onSubmit).
- Ошибки в асинхронном коде (setTimeout, fetch.then).
- Ошибки на стороне сервера во время SSR (там своя история).
- Ошибки в самом Error Boundary.
Это значит, что если у тебя в обработчике клика происходит throw — Error Boundary об этом не узнает. Тебе нужен try/catch на месте или другой механизм.
function DeleteButton({ id }: { id: string }) {
const onClick = async () => {
try {
await api.delete(id);
} catch (e) {
toast.error("Не удалось удалить");
}
};
return <button onClick={onClick}>Удалить</button>;
}
Здесь Error Boundary ничем не помогает. И не должен.
Минимальная реализация
В React нет встроенного компонента ErrorBoundary, его пишут сами или берут из react-error-boundary. Минимальная реализация на классе:
import { Component, type ErrorInfo, type ReactNode } from "react";
type Props = {
fallback: (error: Error, reset: () => void) => ReactNode;
children: ReactNode;
onError?: (error: Error, info: ErrorInfo) => void;
};
type State = { error: Error | null };
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
this.props.onError?.(error, info);
}
reset = () => this.setState({ error: null });
render() {
if (this.state.error) {
return this.props.fallback(this.state.error, this.reset);
}
return this.props.children;
}
}
На практике я всегда беру react-error-boundary — у него удобный API, готовый useErrorBoundary для триггера ошибки изнутри (на async-коде), и нормально типизировано.
import { ErrorBoundary } from "react-error-boundary";
<ErrorBoundary
FallbackComponent={ErrorState}
onError={(error) => logger.error(error)}
>
<Page />
</ErrorBoundary>
Где ставить границы
Главный принцип: граница должна быть достаточно узкой, чтобы пользователь после ошибки мог продолжать пользоваться остальной частью приложения.
Один глобальный Error Boundary в корне делает только одно — заменяет белый экран на свой fallback. Это лучше, чем ничего, но катастрофически мало. Если упал виджет в сайдбаре, я не хочу терять весь UI.
Я обычно ставлю Error Boundary на трёх уровнях.
1. На уровне маршрута (страницы)
В App Router для этого есть отдельный механизм — файл error.tsx в папке маршрута. Он автоматически становится Error Boundary для всего поддерева страницы:
// app/dashboard/error.tsx
"use client";
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div>
<h2>Что-то пошло не так</h2>
<p>{error.message}</p>
<button onClick={reset}>Попробовать снова</button>
</div>
);
}
Если страница «дашборд» рухнула, остальная навигация (шапка, сайдбар) продолжает работать. Пользователь может уйти на другой маршрут.
2. На уровне крупной секции
Внутри страницы — отдельные секции, которые могут упасть независимо. Виджет статистики, лента событий, чат поддержки. Если упал чат — статистика должна продолжать показываться.
<DashboardLayout>
<ErrorBoundary FallbackComponent={SectionError}>
<StatsWidget />
</ErrorBoundary>
<ErrorBoundary FallbackComponent={SectionError}>
<EventsFeed />
</ErrorBoundary>
<ErrorBoundary FallbackComponent={SectionError}>
<SupportChat />
</ErrorBoundary>
</DashboardLayout>
3. Вокруг сторонних виджетов и асинхронных подгружаемых компонентов
Сторонние интеграции — самые частые источники странных ошибок. Виджет рекламы, embed-видео, чужой плеер. Я их всегда оборачиваю отдельно, чтобы не дай бог не уронить весь UI:
<ErrorBoundary fallback={null}>
<Suspense fallback={null}>
<ThirdPartyEmbed />
</Suspense>
</ErrorBoundary>
Тут fallback={null} — нормальный выбор. Реклама не показалась — не показалась, значимая часть UI продолжает жить.
Логирование и мониторинг
Error Boundary без логирования — глаза без зрачков. Перехватывать ошибку и не записывать её куда-то, где её увидят разработчики, — потерянный сигнал.
<ErrorBoundary
FallbackComponent={ErrorState}
onError={(error, info) => {
sentryHub.captureException(error, { extra: info });
}}
>
<Page />
</ErrorBoundary>
В info.componentStack приходит цепочка React-компонентов, в которой произошла ошибка. Это очень помогает в дебаге, но из неё в продакшен-логе видно только имена компонентов — не содержимое пропсов. Если хочется большего контекста — добавляй вручную в extra.
Когда нужен useErrorBoundary
Это хук из react-error-boundary, который позволяет «закинуть» ошибку в ближайшую границу из кода, который Error Boundary сам не ловит — например, из обработчика события или из onError мутации.
import { useErrorBoundary } from "react-error-boundary";
function Form() {
const { showBoundary } = useErrorBoundary();
const onSubmit = async (data: FormData) => {
try {
await api.create(data);
} catch (e) {
if (e instanceof CriticalError) {
showBoundary(e); // улетит в ErrorBoundary выше
} else {
toast.error(e.message);
}
}
};
}
Я использую этот трюк осторожно. Не каждая ошибка должна показывать большой fallback. Сетевая ошибка при сабмите формы — тостом. Падение бизнес-логики, после которого UI не имеет смысла — да, в Error Boundary.
Reset: возврат к нормальному состоянию
Reset нужен, чтобы пользователь мог попробовать снова. Но просто сбросить состояние Error Boundary — мало: если ошибка повторится, ты опять покажешь fallback.
Я обычно делаю так: при reset не только очищаю состояние, но и инвалидирую релевантные запросы или меняю ключ дочернего компонента, чтобы он перерендерился с нуля.
<ErrorBoundary
onReset={() => queryClient.resetQueries()}
resetKeys={[currentUserId]}
>
<UserDashboard />
</ErrorBoundary>
resetKeys — удобная фича react-error-boundary: при их изменении граница сама ресетится. Полезно, когда контекст меняется и старая ошибка перестала быть актуальной.
Частые ошибки
1. Один глобальный ErrorBoundary в корне
Сводит обработку к «всё или ничего». Любая ошибка убивает весь UI и заставляет пользователя обновлять страницу. Не повторяй.
2. Fallback ничего не сообщает
«Ошибка. Попробуйте снова» — это вежливо, но бесполезно. Хотя бы уточни, что упало (виджет, страница, форма) и что пользователь может сделать. Если показываешь технические детали — только в development.
3. Не заворачивать асинхронные компоненты
Если у тебя useSuspenseQuery — оборачивай в Suspense + ErrorBoundary. Иначе при ошибке запроса получишь либо рантайм-падение всего дерева, либо непонятный fallback.
4. Не реагировать на reset
Пользователь жмёт «Попробовать снова», UI возвращается, но запрос так и не повторяется, потому что кэш TanStack Query помнит ошибку. Через секунду снова показывается fallback. Решается через onReset с инвалидацией.
5. Ловить ошибки, которые нужно логировать молча
Иногда хочется, чтобы юзер вообще не заметил ошибки в неважном куске UI. fallback={null} работает, но перед этим обязательно логируй — иначе ты не узнаешь, что что-то ломается.
Что запомнить
Error Boundary — это инструмент локализации сбоев, а не глобального ловца ошибок. Ставь границы вокруг логически независимых кусков UI: страниц, секций, сторонних виджетов. Парь их с Suspense для асинхронных подгрузок и с логированием в твою систему мониторинга. Помни про обработчики событий и async-код — там нужны другие механизмы (try/catch, обработка onError мутаций).
Один глобальный Error Boundary — это лучше, чем ничего, но недостаточно. С тремя-пятью границами в нужных местах сбой одного виджета перестаёт ломать весь UI.