open atlas
↑ К треку
React с нуля до senior RCT · 09 · 01

Error boundary: страховка только из классов и её четыре слепые зоны

Краш рендера без boundary размонтирует всё дерево. Boundary — только класс: getDerivedStateFromError подставляет fallback-состояние, componentDidCatch шлёт отчёт с componentStack. Хендлеры, async, SSR и сам boundary ускользают — закрывайте дыры ярусами route и widget.

RCT Senior ◷ 17 min
Уровень
ОсновыJuniorMiddleSenior
Уже знаешь этот юнит? Пройди быструю проверку за минуту →

Чёрная пятница, 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-коде или в самом фолбэке?

  1. Хендлеры событий. Исключение в onClick происходит вне рендера — React нечего чинить в дереве, поэтому boundary вообще не участвует. Ошибка улетает в window как непойманное исключение, UI остаётся ровно каким был, а пользователь получает кнопку, которая «ничего не делает». try/catch — ваша обязанность.
  2. Асинхронный код. Колбэки setTimeout, цепочки промисов, тело async-эффекта — к моменту исключения стек рендера давно исчез. Отклонённые промисы всплывают как unhandledrejection, а не как пойманная ошибка рендера.
  3. Серверный рендеринг. Классовые boundary — механизм клиентского рантайма. При стриминговом SSR ошибку обрабатывают серверная точка входа (onError стримингового API) и ближайший Suspense-фолбэк — не ваш componentDidCatch.
  4. Сам 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 на фичу. Эпоха хуков поменяла то, как пишут компоненты, а не то, как исключения распространяются по дереву.

Вспомните перед уходом
  1. 01
    Назовите два lifecycle-метода boundary, фазу каждого и какой из них должен заниматься репортингом.
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.