← назад к разделу

React ловит исключения в дереве компонентов через error boundary — это подробно разобрано в статье «Обработка ошибок: error boundary и Suspense». Но есть целый слой ошибок, которые React не видит вообще: асинхронный код вне компонентов, ошибки загрузки ресурсов, непойманные промисы. Их обрабатывают на уровне платформы — JavaScript и браузера.

try/catch и его пределы

try/catch перехватывает синхронные исключения — те, что выбрасываются прямо в текущем стеке вызовов:

try {
  const data = JSON.parse(rawString);
} catch (err) {
  console.error('Не удалось разобрать JSON:', err.message);
}

Проблема: try/catch не ловит ошибки в асинхронном коде, запущенном через setTimeout, setInterval или колбэк события.

try {
  setTimeout(() => {
    throw new Error('эта ошибка не поймается');
  }, 0);
} catch (err) {
  // сюда мы никогда не придём
}

К тому моменту, когда колбэк выполнится, стек try/catch уже завершился. Ошибка уйдёт в глобальный обработчик.

Ошибки в промисах

С Promise ситуация похожа: отклонённый промис без .catch или без await внутри try/catch становится «непойманным» — браузер генерирует событие unhandledrejection.

// правильно: явный .catch
fetch('/api/users')
  .then(res => res.json())
  .catch(err => console.error('Ошибка загрузки:', err));

// правильно: async/await + try/catch
async function loadUser(id) {
  try {
    const res = await fetch(`/api/users/${id}`);
    return await res.json();
  } catch (err) {
    console.error('Ошибка:', err);
    throw err; // пробрасываем дальше, не глотаем
  }
}

async/await делает обработку ошибок промисов идентичной синхронной — try/catch работает как ожидается. Это и есть главная причина, по которой async/await вытеснил цепочки .then. Подробнее о том, почему это так работает, — в статье «Асинхронность и event loop».

Глобальные обработчики браузера

Когда ошибка не поймана локально, браузер генерирует события на window. Это точка входа для систем мониторинга.

window.onerror перехватывает необработанные синхронные исключения и ошибки в колбэках:

window.onerror = function(message, source, lineno, colno, error) {
  sendToMonitoring({ message, source, lineno, colno, stack: error?.stack });
  return true; // подавить вывод в консоли
};

unhandledrejection перехватывает непойманные отклонения промисов:

window.addEventListener('unhandledrejection', (event) => {
  event.preventDefault(); // подавить вывод в консоли
  sendToMonitoring({ reason: event.reason });
});

Эти два обработчика вместе закрывают большую часть «утечек» ошибок. Их регистрируют в точке входа приложения — до монтирования React.

Ошибки загрузки ресурсов

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

window.addEventListener('error', (event) => {
  const target = event.target;
  if (target instanceof HTMLImageElement || target instanceof HTMLScriptElement) {
    sendToMonitoring({ type: 'resource', url: target.src || target.href });
  }
}, true); // true = фаза захвата (capturing)

Без третьего аргумента true этот обработчик не получит события загрузки ресурсов: они не всплывают по дереву DOM.

Собственные классы ошибок

Встроенный Error несёт только сообщение и стек. Для разграничения ошибок по типу полезно создавать собственные классы. С ES2022 удобно использовать свойство cause, которое сохраняет оригинальную ошибку:

class ApiError extends Error {
  constructor(
    message: string,
    public readonly statusCode: number,
    options?: ErrorOptions
  ) {
    super(message, options);
    this.name = 'ApiError';
  }
}

// использование
try {
  const data = await loadData();
} catch (err) {
  throw new ApiError('Не удалось загрузить данные', 500, { cause: err });
}

cause сохраняет оригинальную ошибку — стек не теряется, а при логировании видна вся цепочка причин.

Проверить тип ошибки можно через instanceof:

if (err instanceof ApiError && err.statusCode === 401) {
  redirectToLogin();
}

Мониторинг в продакшене

Обработать ошибку локально — полдела. Если пользователь в продакшене видит сбой, разработчик должен об этом узнать. Для этого ошибки отправляют в систему мониторинга (Sentry, Rollbar или собственный эндпоинт).

Ключевая проблема в продакшене — минификация. После сборки стек ошибки выглядит так:

TypeError: Cannot read properties of undefined
  at e (app.a8f3c2.js:1:4821)

app.a8f3c2.js:1:4821 ни о чём не говорит. Source maps — это файлы, которые бандлер генерирует во время сборки и которые связывают минифицированный код с исходным. Мониторинговый сервис загружает их и восстанавливает читаемый стек:

TypeError: Cannot read properties of undefined
  at loadUserProfile (src/features/profile/ProfilePage.tsx:42:18)

Source maps не стоит выкладывать публично — они раскрывают исходный код. Обычно их загружают напрямую в систему мониторинга через CI и удаляют из публичных ресурсов.

Чего не делать

Глотать ошибки. Пустой catch — один из самых дорогостоящих дефектов: ошибка исчезает, приложение работает некорректно, а причина недоступна. Залогировать и не перебросить — почти то же самое: вызывающий код не знает об ошибке.

// плохо: ошибка исчезла бесследно
try { await saveData(); } catch (e) {}

// хорошо: логируем и пробрасываем
try {
  await saveData();
} catch (e) {
  logger.error('Ошибка сохранения данных', e);
  throw e;
}

Полагаться только на console.error. В продакшене никто не смотрит консоль пользователя. Все нестандартные ошибки должны попадать в систему мониторинга.

Не разграничивать ошибки по типу. Если все ошибки обрабатываются одинаково, невозможно отличить «сеть упала» от «баг в коде» — а это принципиально разные реакции.

Коротко

  • try/catch перехватывает только синхронные исключения и код с await; колбэки setTimeout он не ловит.
  • В промисах: .catch для цепочек; try/catch вокруг await — для async/await.
  • window.onerror + unhandledrejection — глобальная сеть в точке входа приложения, закрывает то, что не поймали локально.
  • Ошибки загрузки ресурсов (<img>, <script>) приходят в addEventListener('error', ..., true) — фаза захвата обязательна.
  • Собственные классы (extends Error, cause) дают тип и контекст без потери стека.
  • В продакшене без source maps стек нечитаем — загружайте их в мониторинг, не публикуйте.
  • Пустой catch хуже, чем отсутствие catch: ошибка исчезает, источник невозможно найти.

Что почитать дальше

  • Обработка ошибок: error boundary и Suspense — как React перехватывает исключения рендера.
  • Асинхронность и event loop — почему try/catch не ловит колбэки и как устроен event loop.
  • Тестирование frontend: Vitest и Testing Library — как тестировать пути ошибок в компонентах.