lenec ru

← все посты

Error Boundaries в React: где ставить и как не превратить в общий catch

13K

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.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.