Error boundary: страховка только из классов и её четыре слепые зоны
Краш рендера без boundary размонтирует всё дерево. Boundary — только класс: getDerivedStateFromError подставляет fallback-состояние, componentDidCatch шлёт отчёт с componentStack. Хендлеры, async, SSR и сам boundary ускользают — закрывайте дыры ярусами route и widget.
Чёрная пятница, 09:14. Карусель рекомендаций на странице товара начала бросать исключения — фид вернул снятый с продажи SKU без объекта цены, и рендер одной карточки вызвал price.amount.toFixed(2) на undefined. Ни одного error boundary в дереве не было. Контракт React начиная с версии 16 беспощаден: непойманная ошибка рендера размонтирует весь корень. Так что умерла не карусель — побелела вся страница: галерея, кнопка «в корзину», навигация, футер. Сорок три минуты пустых экранов в самый трафиковый день года, пока дежурный инженер смотрел на единственную минифицированную строку: Minified React error #310. Фикс, уехавший в тот же день, занял одиннадцать строк — классовый компонент с getDerivedStateFromError вокруг карусели. Фикс, который действительно имел значение, занял ещё квартал: понять, почему следующий инцидент — исключение внутри onClick-хендлера — пролетел мимо новенького boundary и оставил молча мёртвую кнопку вместо фолбэка.
За десять минут вы узнаете, какие именно ошибки boundary ловит, какие четыре категории мимо него проходят и как закрыть эти дыры до следующего дежурства.
Почему React размонтирует всё — и что такое boundary
React 16 принял осознанное решение: испорченный UI хуже, чем отсутствие UI. Если рендер бросил исключение и никто его не поймал, React не может знать, какая часть закоммиченного дерева теперь врёт пользователю — платёжная форма может показывать не ту сумму, медицинский дашборд — не того пациента. Поэтому он размонтирует весь корень. Error boundary — это ваш способ вернуть частичную живучесть: компонент, который говорит React «если что-то ниже меня бросит во время рендера, я отрисую что-то осмысленное вместо этого».
Boundary — это классовый компонент, реализующий один или оба из двух lifecycle-методов, и они делят работу по фазам render/commit:
class WidgetBoundary extends React.Component {
state = { error: null };
static getDerivedStateFromError(error) {
// фаза рендера: должен быть чистым — вывести fallback-состояние, и ничего больше
return { error };
}
componentDidCatch(error, errorInfo) {
// фаза коммита: side-эффекты разрешены — отчитаться один раз, вместе с React-деревом
reportError(error, errorInfo.componentStack);
// componentStack выглядит так: "in PriceTag / in Card / in Carousel / in App"
}
render() {
if (this.state.error) return <CarouselFallback />;
return this.props.children;
}
}getDerivedStateFromError(error) выполняется в фазе рендера, пока React раскручивает стек после исключения — он обязан быть чистым, и его задача — вернуть состояние, переключающее render() на ветку фолбэка. componentDidCatch(error, errorInfo) выполняется в фазе коммита, где side-эффекты легальны — именно туда относится репортинг, и только он получает errorInfo.componentStack: цепочку компонентов, в которых жил краш, — это не то же самое, что JS-стек (цепочка вызовов функций, которая его породила). В отчёте обычно нужны оба.
Почему только классы? Потому что перехват исключения фазы рендера требует lifecycle-метода, который reconciler может вызвать синхронно посреди раскрутки стека. У функционального компонента такой точки крепления нет — всё его тело и есть рендер, и некуда повесить catch, не перезапустив тело целиком. Hook-эквивалента не существует, и документация React говорит об этом прямо. Каждый «функциональный boundary», который вы видели, внутри оборачивает класс.
Команда кладёт вызов Sentry внутрь getDerivedStateFromError, потому что ошибка приходит туда первой. Что с этим не так?
Четыре слепые зоны
Boundary ловит ошибки, брошенные во время рендера, в lifecycle-методах и в конструкторах дерева под ним. Всё остальное ускользает, и эти четыре пути удивляют команды в проде. Прежде чем поставить boundary вокруг виджета, спросите себя: где именно живёт то, что с наибольшей вероятностью упадёт — в рендере, в хендлере, в async-коде или в самом фолбэке?
- Хендлеры событий. Исключение в
onClickпроисходит вне рендера — React нечего чинить в дереве, поэтому boundary вообще не участвует. Ошибка улетает вwindowкак непойманное исключение, UI остаётся ровно каким был, а пользователь получает кнопку, которая «ничего не делает».try/catch— ваша обязанность. - Асинхронный код. Колбэки
setTimeout, цепочки промисов, телоasync-эффекта — к моменту исключения стек рендера давно исчез. Отклонённые промисы всплывают какunhandledrejection, а не как пойманная ошибка рендера. - Серверный рендеринг. Классовые boundary — механизм клиентского рантайма. При стриминговом SSR ошибку обрабатывают серверная точка входа (
onErrorстримингового API) и ближайший Suspense-фолбэк — не вашcomponentDidCatch. - Сам boundary. Исключение в собственном рендере boundary — чаще всего внутри навороченного фолбэка — улетает к следующему boundary выше. Если фолбэк корневого boundary тянет переводы и этот код бросает, вы снова на белом экране. Фолбэки обязаны быть скучными намеренно.
Все четыре категории вместе означают одно: boundary — не полная стратегия, а защита только пути рендера. Каждый другой путь требует явной обработки; пропустите хотя бы один — получите молча мёртвый UI при зелёном дашборде.
У первых двух слепых зон есть практичный мост. Де-факто стандартная обёртка, react-error-boundary, упаковывает класс за вас и добавляет примитив маршрутизации:
import { ErrorBoundary, useErrorBoundary } from "react-error-boundary";
function ExportButton() {
const { showBoundary } = useErrorBoundary();
return (
<button
onClick={async () => {
try {
await exportReport(); // хендлер + async: две слепые зоны разом
} catch (err) {
showBoundary(err); // направить ВНУТРЬ ближайшего boundary
}
}}
>
Экспорт
</button>
);
}
<ErrorBoundary
FallbackComponent={ReportFallback} // получает { error, resetErrorBoundary }
onError={(error, info) => reportError(error, info.componentStack)}
>
<ReportPanel />
</ErrorBoundary>showBoundary(err) заставляет ближайший boundary отрисовать фолбэк так, будто ошибка случилась в рендере — единый путь фолбэка и для крашей рендера, и для сбоев, пойманных вручную. onError централизует репортинг, чтобы каждый boundary не изобретал его заново.
Платёжный виджет обёрнут в ErrorBoundary. Исключение происходит внутри его onSubmit-хендлера. Что увидит пользователь в проде?
Размещение: ярус роутов плюс ярус виджетов
Где ставить boundary — это решение о доступности: каждый boundary задаёт радиус поражения и должен пользователю осмысленный фолбэк:
- Корневой ярус — последний рубеж. Фолбэк — полностраничный экран «что-то сломалось, перезагрузите». Радиус всё ещё 100%, но спроектированные 100% вместо белого экрана. Каждому приложению нужен ровно один.
- Ярус роутов — по boundary на страничный маршрут. Каркас, навигация и остальные роуты живут; фолбэк предлагает путь назад. Радиус: одна страница.
- Ярус виджетов — вокруг независимых рискованных островов: сторонние эмбеды, графики на внешних данных, всё, что рендерит пользовательский контент. Карусель из Hook живёт здесь. Радиус: одна карточка из двадцати.
Антипаттерн — оборачивать каждый компонент «на всякий случай»: десятки одинаковых «упс»-фолбэков дробят страницу на извиняющиеся дыры, и ни для одной никто не проектирует настоящее восстановление. Ярусы заставляют задать правильный вопрос на каждом уровне: что пользователь всё ещё может здесь сделать?
Один продовый нюанс: в разработке оверлей React показывает ошибку, даже когда boundary её поймал, — команды регулярно решают, что их boundary сломан, хотя он работает как задумано. В проде пользователь видит только ваш фолбэк; само сообщение минифицировано до кода ошибки со ссылкой на декодер, а componentStack читается только после применения source maps. React 19 ещё и навёл порядок в логировании: пойманные ошибки логируются один раз через onCaughtError корня вместо дублей в консоли — пайплайновая часть этой истории в уроке 03.
▸Почему это работает
Почему React так и не выпустил хук useErrorBoundary в ядре? Перехват должен случиться, пока reconciler раскручивает стек после исключения — синхронная точка перехвата между исключением и коммитом. Классовые lifecycle дают reconciler именованные слоты, которые он может вызвать посреди раскрутки; функциональный компонент такого слота не предлагает: всё его тело — это рендер, который только что взорвался, и перезапустить его, чтобы «спросить про ошибку», значит перезапустить сам краш. Честный ответ: примитив прекрасно живёт в виде класса, а обёртка ничего не стоит — react-error-boundary это тонкий класс плюс контекст, и кодовой базе нужна примерно одна обёртка, а не по boundary на фичу. Эпоха хуков поменяла то, как пишут компоненты, а не то, как исключения распространяются по дереву.
- 01Назовите два lifecycle-метода boundary, фазу каждого и какой из них должен заниматься репортингом.
- 02Перечислите четыре слепые зоны error boundary и практичную контрмеру для каждой.
Начиная с React 16 непойманная ошибка рендера размонтирует весь корень — React отказывается оставлять на экране UI, который, возможно, врёт, и без boundary одна плохая ячейка кладёт всю страницу. Error boundary — классовый компонент, возвращающий поддереву частичную живучесть через два lifecycle-метода, разнесённых по фазам React: getDerivedStateFromError выполняется чисто во время раскрутки фазы рендера и возвращает состояние, переключающее boundary на фолбэк; componentDidCatch выполняется в фазе коммита, срабатывает один раз на пойманную ошибку и является правильным местом для репортинга — там легальны side-эффекты, и только он получает componentStack: цепочку компонентов, отличную от JS-стека и дополняющую его. Boundary бывают только классами, потому что reconciler нужен синхронный слот перехвата посреди раскрутки, которого тело функции дать не может; react-error-boundary оборачивает класс и добавляет FallbackComponent, onError и useErrorBoundary, чей showBoundary заводит пойманные вручную ошибки на тот же путь фолбэка. У контракта ровно четыре слепые зоны — хендлеры событий (непойманное в window, UI молча цел), асинхронный код (unhandledrejection, стека давно нет), SSR (onError стрима плюс Suspense, а не componentDidCatch) и собственный рендер boundary, включая фолбэк, который ускользает уровнем выше. Размещение — решение о доступности по радиусу поражения: один скучный корневой boundary как последний рубеж, ярус роутов, сохраняющий каркас живым, ярус виджетов вокруг рискованных островов вроде сторонних эмбедов — и ни в коем случае не по boundary на компонент, потому что каждый фолбэк — это спроектированное обещание о том, что пользователь ещё может сделать. В dev оверлей показывает пойманные ошибки всё равно; в проде пользователь видит только ваш фолбэк, а в логах лежат минифицированные коды ошибок, которым нужны source maps и которые с React 19 идут одним потоком через колбэки корня. Теперь, когда вы увидите молча мёртвую кнопку или хендлер, который бросает без алерта на дашборде, — вы будете знать: нужен try/catch плюс showBoundary, а не ещё один boundary-враппер.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.