open atlas
↑ К треку
Паттерны React RXP · 11 · 02

Error boundaries

Suspense обрабатывает состояние загрузки; Error Boundary над ним обрабатывает состояние отказа, перехватывая ошибки при рендеринге и показывая запасной UI — настоящему async-UI нужны оба, а Suspense без границы пускает упавший fetch в белый экран.

RXP Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

Ты научился ставить границы Suspense близко к данным, чтобы страница раскрывалась прогрессивно. И вот ты оборачиваешь свою async-ленту в <Suspense>, даёшь ей скелетон, и она красиво подгружается. Потом сервис рекомендаций упирается в таймаут — и страница белеет. Пусто. Ни скелетона, ни ошибки, ни восстановления. Просто пустой экран и стектрейс в консоли, который пользователь никогда не увидит.

Suspense ответил на один из двух вопросов, которые задаёт async-регион, — что показать, пока идёт загрузка? — и молча проигнорировал второй: что показать, когда она упала? У <Suspense> без Error Boundary над ним нет ответа на отказ, поэтому брошенная ошибка всплывает дальше, пока не размонтирует всё твоё дерево. Каждой границе Suspense нужна Error Boundary над ней. Этот урок — про эту пару.

Цель

После этого урока ты можешь объяснить, что Error Boundary перехватывает ошибки, брошенные при рендеринге (и из дочернего Suspense, у которого fetch данных отклонился), и показывает запасной UI вместо падения; ставить Error Boundary над каждым Suspense, чтобы и загрузка, и отказ были обработаны; подключать reset, чтобы пользователь мог повторить попытку без полной перезагрузки; и назвать острый край — ошибки, брошенные в обработчиках событий и эффектах, не перехватываются Error Boundaries и должны обрабатываться отдельно.

1

Error Boundary перехватывает ошибки, брошенные при рендеринге, и показывает запасной UI вместо размонтирования дерева. Это компонент, оборачивающий поддерево: если любой потомок бросает ошибку при рендеринге, граница подменяет это поддерево своим запасным UI. На момент React 19 хук-формы по-прежнему нет — error boundary это классовый компонент, реализующий getDerivedStateFromError (чтобы отрендерить запасной UI) и опционально componentDidCatch (чтобы залогировать). Пакет react-error-boundary — стандартная обёртка, которую большинство команд используют вместо самописной.

import { Component, type ReactNode } from "react";

class ErrorBoundary extends Component<
  { fallback: ReactNode; children: ReactNode },
  { hasError: boolean }
> {
  state = { hasError: false };
  static getDerivedStateFromError() {
    return { hasError: true };   // отрендерить запасной UI на следующем рендере
  }
  componentDidCatch(error: Error) {
    reportToSentry(error);       // побочный эффект: залогировать
  }
  render() {
    return this.state.hasError ? this.props.fallback : this.props.children;
  }
}

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

2

Suspense обрабатывает загрузку; Error Boundary обрабатывает отказ — настоящему async-UI нужны оба, поставленные стопкой. Ожидающий fetch данных приостанавливается (Suspense ловит это и показывает запасной UI). Отклонённый fetch данных бросает ошибку (Error Boundary ловит её и показывает свой запасной UI). Это два разных сигнала для двух разных состояний, перехватываемых двумя разными границами. Error Boundary идёт над Suspense, потому что отказ должен заменить весь регион загрузки — включая его скелетон, — а не рендериться внутри него.

// Пара. Внешняя = состояние отказа, внутренняя = состояние загрузки.
<ErrorBoundary fallback={<FeedError />}>
  <Suspense fallback={<FeedSkeleton />}>
    <ActivityFeed />   {/* ожидание → скелетон; отклонение → FeedError */}
  </Suspense>
</ErrorBoundary>

Читай сверху вниз: пока данные ActivityFeed ожидаются, пользователь видит FeedSkeleton; если они разрешаются, он видит ленту; если отклоняются, брошенная ошибка ускользает из Suspense, попадает в Error Boundary, и он видит FeedError. Три состояния, полностью обработаны.

3

Дай границе reset, чтобы пользователь мог повторить упавший регион без перезагрузки страницы. Перехват-и-показ запасного UI без пути назад — это тупик. react-error-boundary отдаёт твоему запасному UI колбэк resetErrorBoundary(): его вызов сбрасывает состояние ошибки и перерендеривает детей, что заново запускает приостановленный fetch. Сочетай его с массивом resetKeys, чтобы граница ещё и автоматически сбрасывалась при изменении входа (например, id пользователя).

import { ErrorBoundary } from "react-error-boundary";

function FeedSection({ userId }: { userId: string }) {
  return (
    <ErrorBoundary
      FallbackComponent={FeedError}
      resetKeys={[userId]}         // авто-сброс при изменении userId
    >
      <Suspense fallback={<FeedSkeleton />}>
        <ActivityFeed userId={userId} />
      </Suspense>
    </ErrorBoundary>
  );
}

function FeedError({ resetErrorBoundary }: { resetErrorBoundary: () => void }) {
  return (
    <div role="alert">
      <p>Couldn't load the feed.</p>
      <button onClick={resetErrorBoundary}>Retry</button>
    </div>
  );
}

Теперь временный отказ восстановим на месте: пользователь жмёт Retry, регион возвращается в свой FeedSkeleton, и fetch выполняется снова — без перезагрузки всей страницы, без потери остального экрана.

4

Острый край: Error Boundaries ловят только ошибки фазы рендеринга. Ошибки в обработчиках событий, эффектах, таймаутах и async-колбэках НЕ перехватываются. Это режим отказа, который застаёт людей врасплох. getDerivedStateFromError выполняется во время рендеринга, поэтому видит только те ошибки, что брошены при рендеринге (включая дочерний Suspense, чьё чтение отклонилось). Ошибка внутри onClick, setTimeout, fetch().then(...) или тела useEffect происходит вне рендеринга — React не направляет её к границе, и она либо молча роняет обработчик, либо всплывает как необработанное отклонение.

function SaveButton() {
  // ❌ НЕ перехватывается ни одной Error Boundary — это обработчик, не рендеринг.
  async function onSave() {
    await api.save();          // если это отклонится, граница её не увидит
  }
  return <button onClick={onSave}>Save</button>;
}

// ✅ Обрабатывай ошибки событий/эффектов сам — try/catch + локальное состояние ошибки.
function SaveButton() {
  const [err, setErr] = useState<string | null>(null);
  async function onSave() {
    try { await api.save(); }
    catch { setErr("Save failed. Try again."); }
  }
  return (
    <>
      <button onClick={onSave}>Save</button>
      {err && <p role="alert">{err}</p>}
    </>
  );
}

Так что Error Boundaries необходимы, но недостаточны: они покрывают путь рендеринга (загрузка данных для компонентов, читающих при рендеринге, плохие пропсы, компонент, бросающий ошибку при рендеринге), и ты по-прежнему должен отдельную стратегию — try/catch плюс локальное состояние или обработку ошибок твоего слоя данных — для всего, что происходит в обработчиках и эффектах.

Разбор примера

Виджет дашборда: от Suspense, способного забелить страницу, к полностью защищённому региону. <RevenueChart> читает из ненадёжного аналитического эндпоинта. Первая версия обрабатывает загрузку, но не отказ.

До — только Suspense. Когда эндпоинт отдаёт 500, чтение отклоняется, брошенная ошибка ускользает из Suspense, и без границы в дереве она распространяется до корня и размонтирует весь дашборд:

export default function Dashboard() {
  return (
    <DashboardLayout>
      <Header />
      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />   {/* отклоняется → ошибка всплывает к корню → белая страница */}
      </Suspense>
    </DashboardLayout>
  );
}
// Загрузка: норм. Отказ: весь дашборд — заголовок и всё прочее — белеет.

После — Error Boundary над Suspense, ограниченная только этим виджетом, с повтором:

import { ErrorBoundary } from "react-error-boundary";

export default function Dashboard() {
  return (
    <DashboardLayout>
      <Header />
      <ErrorBoundary FallbackComponent={ChartError}>
        <Suspense fallback={<ChartSkeleton />}>
          <RevenueChart />
        </Suspense>
      </ErrorBoundary>
    </DashboardLayout>
  );
}

function ChartError({ resetErrorBoundary }: { resetErrorBoundary: () => void }) {
  return (
    <div role="alert" className="chart-error">
      <p>Revenue data is unavailable right now.</p>
      <button onClick={resetErrorBoundary}>Retry</button>
    </div>
  );
}

Теперь отказ локализован: заголовок, раскладка и все остальные виджеты продолжают рендериться; только слот графика показывает встроенную ошибку с рабочим Retry. Тот же Suspense, те же данные, тот же ненадёжный эндпоинт — но упавший fetch теперь восстановимое встроенное сообщение, а не белый экран. Граница нарочно ограничена жёстко (один виджет): единственная Error Boundary в корне маршрута заменила бы весь дашборд при отказе любого одного виджета — ровно та проблема «всё или ничего», от которой тебя учило размещение, применённая к ошибкам вместо загрузки.

Почему это работает

Почему Error Boundary должна стоять над Suspense, а не рядом или под ним? Потому что ошибка исходит из того же ребёнка, который приостанавливается, — компонента, читающего данные при рендеринге. Когда его чтение отклоняется, брошенная ошибка движется вверх по дереву в поисках ближайшей границы, проходя прямо сквозь Suspense (Suspense перехватывает только сигнал ожидания, не брошенные ошибки). Первая Error Boundary, которую она встретит на пути вверх, её ловит. Если бы Error Boundary была внутри Suspense, ошибка уже покинула бы поддерево Suspense раньше, чем достигла её. Над-Suspense — единственная позиция, которая ловит отказ из региона, охраняемого Suspense, — поэтому канонная форма всегда ErrorBoundary > Suspense > async-ребёнок.

Частая ошибка

Самая дорогая ошибка — считать «я добавил Suspense» равным «я обработал async-регион». Suspense — половина контракта. Рефлекс, который надо выработать: каждый раз, когда рисуешь границу Suspense, рисуй Error Boundary над ней в той же правке — сделай пару единой ментальной единицей. Вторая ловушка — поставить одну Error Boundary в корне приложения и счесть дело сделанным: технически это останавливает белый экран, но теперь любой отказ где угодно заменяет всё твоё приложение одной общей страницей ошибки, выбрасывая каждый другой регион, отрендерившийся нормально. Ограничивай error boundaries теми же независимыми регионами, что и Suspense, чтобы отказ одного виджета стоил тебе одного виджета — а не всего экрана.

Проверь себя
Викторина

Компонент оборачивает async-<UserList> (читает данные при рендеринге) в <Suspense fallback={<Skeleton/>}> без Error Boundary. Есть также кнопка «Refresh», чей onClick вызывает await refetch(). Эндпоинт начинает отдавать 500 и при первом рендере, и при обновлении. Что произойдёт и в чём senior-исправление?

Итог

У async-региона три состояния, и один Suspense покрывает лишь одно. Suspense обрабатывает загрузку (ожидающее чтение приостанавливается → показать скелетон). Error Boundary обрабатывает отказ (отклонённое чтение бросает ошибку при рендеринге → показать запасной UI ошибки). Они ставятся стопкой, с Error Boundary над Suspense, потому что отказ должен заменить весь регион загрузки. В React 19 error boundary по-прежнему класс (getDerivedStateFromError + componentDidCatch), а react-error-boundary — стандартная обёртка — дай её запасному UI resetErrorBoundary()resetKeys), чтобы пользователи могли повторить на месте вместо перезагрузки. Режим отказа, который этот урок убивает: Suspense без Error Boundary, где упавший fetch всплывает мимо корня и белит экран. И острый край: Error Boundaries ловят только ошибки фазы рендеринга — ошибки обработчиков событий и эффектов не перехватываются и нуждаются в собственном try/catch плюс локальном состоянии. Ограничивай error boundaries теми же независимыми регионами, что и границы Suspense, чтобы отказ одного виджета стоил одного виджета, а не всего приложения. Каждой границе Suspense нужна Error Boundary над ней.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.