Границы ошибок — единственное место, где ещё нужен класс

свои хуки≈ 13 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Опечатка в одном компоненте не портит его одного: если ошибку никто не поймал, React снимает с экрана всё дерево целиком — пустая страница из-за одной сломанной карточки. Огородить ветку можно единственным способом: компонентом-классом со статическим методом getDerivedStateFromError. Это последнее место в современном React, где класс не заменяется хуком.

Разделы темы:·

В части II, когда перерисовка из состояния писалась руками, ошибка в одном куске разметки оставалась его личным делом: try/catch вокруг вызова — и остальная страница живёт дальше. React так не умеет. В примере ниже рядом живут счётчик и отчёт: жмём на счётчик, потом на «Сломать отчёт» — и печатаем, что осталось внутри #root. Прогноз: исчезнет один абзац с отчётом или что-то ещё?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
в корне осталось: "" — пусто. Ошибку при рендере Report никто не поймал, и React размонтировал весь корень: счётчик, обе кнопки, всё. Дело не в суровости, а в честности. Рендер прервался на середине: часть описания посчитана, часть — нет, какие-то узлы обновлены, какие-то остались от прошлого состояния. Показать приложение, про которое неизвестно, что в нём правда, хуже, чем не показать ничего: по такому экрану кликают, отправляют формы и переводят деньги.

Строка «Uncaught Error: …» в выводе — не чей-то console.log. Непойманную ошибку React докладывает наружу как необработанную, и её видит window.onerror — глобальный обработчик браузера для всего, что никто не поймал.

Для странички из одного экрана это ещё терпимо. Для панели с десятком виджетов — нет: сломавшийся курс валют не должен уносить с собой почту, календарь и список задач. Нужен способ сказать React: вот эта ветка отдельная, если внутри упадёт — гаси только её.

Такая ветка называется границей ошибок. Граница — компонент, который React узнаёт по одному признаку: у его класса есть статический метод getDerivedStateFromError. Прежде чем гасить дерево, React идёт от места падения вверх и ищет ближайшего такого предка. Нашёл — сносит только то, что было внутри границы, и рисует саму границу заново, положив ошибку ей в состояние.

Метод статический и вызывается во время рендера, поэтому обязан быть чистым: посчитать и вернуть новое состояние, больше ничего. Всё остальное — обычный класс из части I: поле state, метод render, this.props. Пример тот же, только Report обёрнут в границу, а порядок нажатий обратный: сначала ломаем отчёт, потом жмём счётчик. Прогноз: что окажется в корне и что покажет счётчик, если уцелеет, — 0 или 1?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

В корне остались обе кнопки и абзац запасной разметки, а счётчик показывает Нажато: 1 — он не просто уцелел, он сохранил состояние: React не тронул ничего за пределами границы. Граница — не try/catch вокруг приложения, а выключатель на одной ветке дерева.

Красная строка в выводе — сам React: пойманную ошибку он всё равно печатает через console.error, дописывая, в каком компоненте она случилась и какая граница её перехватила. В DevTools на этом месте будет разворачиваемая ошибка со стеком.

Про типы: React.Component<BoundaryProps, BoundaryState> — два параметра-дженерика, пропсы и состояние; после части III это читается без подсказок. Неожиданность одна, и она для тех, кто видел старые примеры: в React 19 children в пропсах не появляется сам собой — если граница принимает детей, свойство нужно объявить.

Соблазн хранить сразу текст ошибки понятен, но getDerivedStateFromError получает не строку. Пример ниже ничего не выводит — он только про диагностику на тринадцатой строке.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

И ещё одна честность: бросить в JavaScript можно что угодно — строку, число, объект без message. Типизировать параметр как Error — соглашение, а не гарантия. Если в поддереве живёт чужой код, надёжнее принять unknown и сузить его так, как это делалось в части III.

Второй метод границы — componentDidCatch. Его вызывают уже после коммита, когда запасная разметка на экране, и существует он ради побочных действий: записать в журнал, отправить в сервис отслеживания ошибок, увеличить счётчик метрики. Вместе с ошибкой он получает объект с componentStack — путём от упавшего компонента до корня.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Стек компонентов — не стек вызовов JavaScript: в нём нет ни внутренностей React, ни setState, только цепочка из разметки, по которой видно, какой именно Row из списка упал. Работает он и в собранном для продакшена приложении — правда, имена компонентов там могут оказаться сокращёнными сборщиком.

getDerivedStateFromErrorcomponentDidCatch
Когда вызываетсяво время рендера, вместо упавшего поддеревапосле коммита запасной разметки
Рольрешает, что показать вместо поддереварешает, что сделать с ошибкой
Аргументыошибкаошибка и объект с componentStack
Статическийда, this внутри нетнет, обычный метод
Побочные действиязапрещеныради них он и нужен

Разделение не формальное. Рендер React имеет право выполнить, отбросить и запустить заново — это разбиралось в статье «Триггер, рендер, коммит». Отправка отчёта из getDerivedStateFromError ушла бы в сервис дважды. componentDidCatch вызывается после коммита, один раз на падение, — там ей и место.

Граница ловит падения детей, но не свои собственные. Если упадёт запасная разметка, ошибка полетит дальше вверх — к следующей границе.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Внешняя граница поймала уже вторую ошибку, первую она не видела. Не будь её — приложение погасло бы целиком, как в первом примере. Отсюда правило: запасная разметка должна быть максимально скучной. Никаких обращений к тем же данным, которые только что уронили поддерево, никаких вычислений — текст и кнопка.

Граница ловит не «ошибки в компоненте», а ошибки, случившиеся внутри работы React: при вызове функции-компонента, при вычислении разметки, в конструкторе класса и в теле эффектов. Остальное летит мимо. Ниже два виджета, оба падают из эффекта, разница в одной строке. Прогноз: какие границы покажут запасную разметку?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
Граница A поймала, граница B — нет, и второй виджет остался на экране как ни в чём не бывало. Тело эффекта выполняет сам React сразу после коммита: он знает, чей это эффект, и умеет отправить ошибку вверх по дереву. Колбэк таймера через 30 мс вызывает уже браузер, из пустого стека, — React в этот момент ничем не занят и о связи этого вызова с Late не подозревает. С промисами то же самое: .then и всё, что стоит после await, — отдельная задача из статьи про микрозадачи, и упавшая там ошибка до границы не дойдёт.

Отдельным пунктом — обработчики событий. Их React вызывает сам, но ошибки из них не перехватывает намеренно: рендер можно переиграть, нажатие — нет, и гасить пол-экрана из-за неудачного клика хуже, чем оставить экран как есть. В примере ниже обе кнопки зовут один и тот же падающий sendToServer. Прогноз: какая из них доведёт ошибку до границы?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первая кнопка не изменила ничего: ошибка ушла в window.onerror, приложение осталось на месте. Вторая сработала, хотя падает та же функция. Всё дело в трёх словах — if (failure) throw failure. Обработчик поймал ошибку сам, положил в состояние и вызвал перерисовку, а во время рендера бросил её заново. Теперь это ошибка рендера, а такие граница видит.

Это официальный способ отдать границе то, что случилось вне рендера: сохранить ошибку в состоянии и бросить при следующей отрисовке. Обратите внимание на catch (error) — под strict переменная имеет тип unknown, и сужать её до Error приходится руками, ровно как в части III.

Асинхронный код внутри эффекта — не исключение из правила, а тот же таймер: await разрывает стек, и всё после него выполняется отдельной задачей.

Граница, поймавшая ошибку, останется в состоянии «сломано» навсегда: this.state.error никто не обнулит. Перерисовки родителя её не лечат — состояние принадлежит месту в дереве, а место не менялось («Когда состояние сохраняется, а когда сбрасывается»). Значит, кнопка «попробовать снова» делает два дела: убирает причину и пересоздаёт границу. Второе — тем же key, что и в той статье.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Смена key заставляет React считать границу другим компонентом на том же месте: старую он размонтирует вместе с состоянием, новую смонтирует с чистым error: null, и дети отрисуются заново. Одного сброса мало: не поправь retry ещё и broken, отчёт упал бы снова, и граница честно показала бы ту же ошибку.

Сброс имеет смысл только вместе с попыткой исправить причину: перезапросить данные, вернуть на предыдущий шаг, сменить фильтр. Иначе выйдет кнопка, которая ничего не делает.

За годы React вынес в хуки всё: состояние, эффекты, контекст, подписки. Границы остались классами. Хука useErrorBoundary в React нет, и документация его не обещает — она предлагает либо написать этот класс на дюжину строк один раз и забыть, либо взять пакет react-error-boundary, где есть и готовый компонент, и хук, чтобы отправить в границу ошибку из обработчика.

Причина видна из самого метода. Решение о том, что показать вместо упавшего поддерева, принимается во время того же рендера, в котором поддерево упало. Функция-компонент к этому моменту уже отработала и вернула описание; чтобы дать ей второй шанс, нужна точка снаружи от неё, вызываемая до следующего её вызова, — а это в точности статический метод класса. Придумать вместо него хук можно, но придётся договориться, как он объявляет компонент границей, что делать с падением на первом же рендере и как всё это уживается с Suspense. Договорённости нет — класс остался.

Границ в приложении обычно несколько, и уровни у них разные. Одна на всё дерево спасает от белого экрана, но не спасает соседей по странице.

Где границаЧто показываетЗачем
Вокруг всего приложения«Что-то пошло не так» и кнопку перезагрузкичтобы вместо пустоты было хоть что-то
Вокруг раздела или вкладкизаглушку на месте разделападение раздела не уносит навигацию
Вокруг элемента спискастроку-заглушкуодна битая запись не прячет остальные
Вокруг чужих данных и виджетовзапасную разметку виджетачужой ответ меняется без предупреждения

У промиса два исхода, и React закрывает их разными компонентами: ожидание — Suspense из прошлой статьи, отказ — граница. Промис, переданный в use() и завершившийся ошибкой, бросает её во время рендера, а такую ошибку граница ловит как любую другую. Поэтому пара «граница снаружи, Suspense внутри» встречается почти в каждом дереве.

Во фреймворках границы расставлены заранее: в Next.js файл error.tsx рядом с маршрутом превращается в границу вокруг этого участка и, по требованию фреймворка, обязан быть клиентским компонентом. Подробности — в главе про App Router.

  • Непойманная ошибка рендера размонтирует всё дерево: пустой экран для React честнее, чем экран в неизвестном состоянии.
  • Граница ошибок — компонент-класс со статическим getDerivedStateFromError; React находит ближайшего такого предка и показывает его запасную разметку вместо упавшего поддерева.
  • componentDidCatch вызывается после коммита и нужен для побочных действий — журнала, отправки в сервис; вместе с ошибкой он получает componentStack.
  • Граница не ловит обработчики событий, таймеры, промисы и собственные падения; чтобы отдать ей такую ошибку, её кладут в состояние и бросают при следующем рендере.
  • Состояние границы само не сбрасывается: поддерево возвращают сменой key вместе с устранением причины.
  • Хука-замены нет и не обещано — либо свой класс на дюжину строк, либо пакет react-error-boundary.

1/4Компонент упал при рендере, ни одной границы в дереве нет. Что произойдёт с приложением?

2/4Внутри границы два виджета. Первый бросает ошибку прямо в теле useEffect, второй — в колбэке setTimeout, заведённого в таком же эффекте. Что поймает граница?

3/4Пойманную ошибку нужно отправить в сервис отслеживания. Где это делать?

4/4Граница поймала ошибку и показала запасную разметку. Родитель перерисовался с новыми данными. Что увидит пользователь?

Своя граница

Класс объявлен, состояние есть, а границей он пока не является: Report падает, и корень остаётся пустым. Допишите класс так, чтобы вместо упавшего поддерева показывался переданный в fallback узел.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Границей класс делает статический getDerivedStateFromError: React вызовет его вместо упавшего поддерева и положит возвращённый объект в состояние. Дальше render просто выбирает, что показать.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

В корне остаётся <p>Отчёт временно недоступен</p>. Обратите внимание, что fallback имеет тип React.ReactNode — туда одинаково годятся и строка, и целое поддерево.

Ошибка из обработчика

Кнопка зовёт sendToServer, тот падает — а граница молчит: обработчики она не ловит. Сделайте так, чтобы падение всё-таки до неё дошло. Класс границы менять не нужно.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ошибку нужно перевести из обработчика в рендер: поймать, положить в состояние и бросить при следующей отрисовке — тогда она станет ошибкой рендера, а такие граница видит.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Строка if (failure) throw failure стоит до всех вычислений компонента: как только состояние заполнено, рендер сразу падает и до разметки дело не доходит. Сужение error instanceof Error нужно потому, что под strict переменная в catch имеет тип unknown.

Граница на каждую строку

В таблице три месяца, у февраля нет итога, и Row на нём падает. Сейчас одна граница обёрнута вокруг всего списка, и вместо трёх строк остаётся одна заглушка. Переставьте границу так, чтобы январь и март остались на месте.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Граница гасит своё поддерево целиком, поэтому размер поддерева и решает, что именно пропадёт с экрана. Переносим границу внутрь map: у каждой строки будет своя, со своим состоянием.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Получится Январь: 12400, заглушка вместо февраля и Март: 9800. key переезжает на границу — теперь именно она стоит в списке, а Row внутри неё единственный ребёнок.