Все хотят писать производительные, функциональные и при этом стабильные приложения. Но так как всем людям свойственно ошибаться, кода без ошибок не бывает. Независимо от уровня внимательности и количества написанных тестов всегда что-то может пойти не так. Поэтому с точки зрения пользовательского опыта важно предсказать появление проблемы, локализовать и устранить ее.
Рассмотрим обработку ошибок в React: что делать при их появлении, как их выявить и устранить.
Почему нужно находить ошибки в React
Начиная с 16-й версии React, возникающая во время жизненного цикла ошибка приводит к размонтированию всего приложения, если его не остановить. Ранее компоненты сохранялись на экране, даже если были искажены и не функционировали должным образом. Теперь уничтожить страницу полностью и отобразить пустой экран может досадная необнаруженная ошибка в незначительной части пользовательского интерфейса или даже в неконтролируемой разработчиком внешней библиотеке.
Выявление ошибок в JavaScript
В обычном JavaScript для выявления ошибок есть довольно простые инструменты. Например, оператор try/catch
: попытаться (try
) что-то выполнить, а если не получится, то поймать (catch
) ошибку и сделать что-нибудь, чтобы минимизировать ее последствия.
try {
// некорректная операция может вызвать ошибку
doSomething();
} catch (e) {
// если ошибка произошла, ловим ее и делаем что-нибудь без остановки приложения,
// например отправляем ее в службу регистрации
}
Для функции async
синтаксис будет такой же:
try {
await fetch('/bla-bla');
} catch (e) {
// Выборка не удалась! С этим нужно что-то делать!
}
Для традиционных промисов есть метод catch
. Предыдущий пример fetch
с API на основе промиса можно переписать так:
fetch('/bla-bla').then((result) => {
// Если промис выполнен успешно, результат будет здесь,
// с ним можно сделать что-нибудь полезное
}).catch((e) => {
// О нет, выборка не удалась! Нужно что-то с этим сделать!
})
Это та же концепция, только немного другая реализация, поэтому и далее для всех ошибок используем синтаксис try/catch
.
Простой try/catch в React: как правильно его выполнить
С пойманной ошибкой нужно что-то делать кроме того, чтобы записать ее куда-нибудь. Иначе говоря, что можно сделать, чтобы упростить жизнь пользователю? Не стоит оставлять его с пустым экраном или неработающим интерфейсом.
Наиболее очевидным и интуитивно понятным решением будет рендеринг на экране чего-либо до исправления ситуации. К счастью, оператор catch
предоставляет для этого ряд возможностей, включая установку состояния. Например:
const SomeComponent = () => {
const [hasError, setHasError] = useState(false);
useEffect(() => {
try {
// делаем что-либо, например выборку данных
} catch(e) {
// выборка не прошла, данных для рендеринга нет!
setHasError(true);
}
})
// что-то произошло во время выборки, отобразим красивый экран с ошибкой
if (hasError) return <SomeErrorScreen />
// данные есть - отрендерим их
return <SomeComponentContent {...datasomething} /
Мы пытаемся отправить запрос на выборку данных. В случае неудачи устанавливаем состояние ошибки и, если оно равно true
, отображаем экран ошибки с дополнительной информацией для пользователя, например номером службы поддержки.
Этот способ подходит для простых, предсказуемых и ограниченных вариантов использования, таких как обнаружение неудачного запроса fetch
.
Но если вы захотите отловить все возможные варианты ошибок в компоненте, то столкнетесь с определенными проблемами и серьезными ограничениями.
Ограничение 1: проблемы с хуком useEffect
Если просто обернуть useEffect
с помощью try/catch
, это не сработает:
try {
useEffect(() => {
throw new Error('Hulk smash!');
}, [])
} catch(e) {
// useEffect выбрасывается, но не вызывается
}
Дело в том, что useEffect
вызывается асинхронно после рендеринга, поэтому для try/catch
все проходит успешно. Подобное происходит и с любым Promise: если не ожидать результата, JavaScript просто продолжит свое дело, вернется к нему, когда промис будет выполнен, и выполнит только то, что находится внутри useEffect
(и затем промиса). Выполненный блок try/catch
исчезнет к тому времени.
Чтобы отлавливать ошибки внутри useEffect
, нужно также поместить try/catch
внутрь:
useEffect(() => {
try {
throw new Error('Hulk smash!');
} catch(e) {
// эта ошибка будет перехвачена
}
}, [])
Поэкспериментируйте с этим примером.
Это относится к любому хуку, использующему useEffect
, и ко всем асинхронным действиям. В результате вместо одного try/catch
, обертывающего все, придется разбить его на несколько блоков: по одному на каждый хук.
Ограничение 2: дочерние компоненты
try/catch
не сможет поймать ошибку внутри дочерних компонентов. Например:
const Component = () => {
let child;
try {
child = <Child />
} catch(e) {
// бесполезен для отлова ошибок внутри дочернего компонента, не будет запускаться
}
return child;
}
Или даже так:
const Component = () => {
try {
return <Child />
} catch(e) {
// по-прежнему бесполезен для обнаружения ошибок внутри дочернего компонента, не будет запускаться
}
}
Убедитесь на этом примере.
После Child />
нет реального рендеринга компонента. Мы создаем Element
компонента, который является его определением. Это просто объект, который содержит необходимую информацию, такую как тип компонента и реквизиты, которые позже будут использоваться самим React, что фактически и вызовет рендеринг этого компонента. И произойдет это после успешного выполнения блока try/catch
. Та же ситуация, что с промисами и хуком useEffect
.
Ограничение 3: нельзя установить состояние во время рендеринга
Если попытаться отловить ошибки вне useEffect
и различных обратных вызовов (т. е. во время рендеринга компонента), то разобраться с ними должным образом уже не так просто: обновления состояния во время рендеринга не допускаются.
Вот пример простого кода, который вызовет бесконечный цикл повторных рендеров, если произойдет ошибка:
const Component = () => {
const [hasError, setHasError] = useState(false);
try {
doSomethingComplicated();
} catch(e) {
// недопустимый вариант! В случае ошибки вызовет бесконечный цикл
// см. реальный пример в codesandbox ниже
setHasError(true);
}
}
Убедитесь сами в codesandbox.
Конечно, можно просто отобразить экран ошибки вместо установки состояния:
const Component = () => {
try {
doSomethingComplicated();
} catch(e) {
// допустимый вариант
return <SomeErrorScreen />
}
}
Но это немного громоздко и заставит по-разному обрабатывать ошибки в одном и том же компоненте: состояние для useEffect
и обратных вызовов, а также прямой возврат для всего остального.
// это рабочий, но громоздкий вариант, не заслуживающий внимания
const SomeComponent = () => {
const [hasError, setHasError] = useState(false);
useEffect(() => {
try {
// делаем что-либо, например выборку данных
} catch(e) {
// невозможен простой return в случае ошибок в useEffect и callbacks,
// поэтому приходится использовать состояние
setHasError(true);
}
})
try {
// делаем что-либо во время рендеринга
} catch(e) {
// но здесь мы не можем использовать состояние, поэтому в случае ошибки нужно возвращать напрямую
return <SomeErrorScreen />;
}
// и все же нужен return в случае ошибки состояния
if (hasError) return <SomeErrorScreen />
return <SomeComponentContent {...datasomething} />
}
В итоге, если в React полагаться исключительно на try/catch
, то мы либо пропустим большую часть ошибок, либо превратим каждый компонент в непонятную смесь кода, которая, вероятно, сама по себе вызовет ошибки.
К счастью, есть и другой способ.
Компонент React ErrorBoundary
Обойти отмеченные выше ограничения позволяет React Error Boundaries. Это специальный API, который превращает обычный компонент в оператор try/catch
в некотором роде только для декларативного кода React. Типичное использование будет примерно таким:
const Component = () => {
return (
<ErrorBoundary>
<SomeChildComponent />
<AnotherChildComponent />
</ErrorBoundary>
)
}
Теперь, если в этих компонентах или их дочерних элементах что-то пойдет не так во время рендеринга, ошибка будет обнаружена и обработана.
Но React не предоставляет компонент как таковой, а просто дает инструмент для его реализации. Простейшая реализация будет примерно такой:
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
// инициализировать состояние ошибки
this.state = { hasError: false };
}
// если произошла ошибка, установите состояние в true
static getDerivedStateFromError(error) {
return { hasError: true };
}
render() {
// если произошла ошибка, вернуть резервный компонент
if (this.state.hasError) {
return <>Oh no! Epic fail!</>
}
return this.props.children;
}
}
Мы создаем компонент класса regular
и реализуем метод getDerivedStateFromError
, который возвращает компонент в надлежащие границы ошибок.
Кроме того, при работе с ошибками важно отправить информацию о них в сервис обработки. Для этого в Error Boundary есть метод componentDidCatch
:
class ErrorBoundary extends React.Component {
// все остальное остается прежним
componentDidCatch(error, errorInfo) {
// отправить информацию об ошибке
log(error, errorInfo);
}
}
После настройки границ ошибок с ними можно работать, как и с любым другим компонентом. Например, можно сделать его более пригодным для повторного использования и передать резервный вариант в качестве реквизита:
render() {
// если произошла ошибка, вернуть резервный компонент
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
Используем таким образом:
const Component = () => {
return (
<ErrorBoundary fallback={<>Oh no! Do something!</>}>
<SomeChildComponent />
<AnotherChildComponent />
</ErrorBoundary>
)
}
Можно выполнять и другие задачи, например сброс состояния при нажатии кнопки, дифференциацию ошибок по типам и отправку ошибки в контекст.
Полный пример в codesandbox.
Однако есть одно предостережение: улавливаются не все ошибки.
Компонент ErrorBoundary: ограничения
ErrorBoundary
улавливает только те ошибки, которые возникают во время жизненного цикла React. Все происходящее за его пределами, включая разрешенные промисы, асинхронный код с setTimeout
, различные обратные вызовы и обработчики событий, просто исчезнет, если не будут обработано явно.
const Component = () => {
useEffect(() => {
// будет пойман компонентом ErrorBoundary
throw new Error('Destroy everything!');
}, [])
const onClick = () => {
// эта ошибка просто исчезнет в void
throw new Error('Hulk smash!');
}
useEffect(() => {
// если это не сработает, ошибка тоже исчезнет
fetch('/bla')
}, [])
return <button onClick={onClick}>click me</button>
}
const ComponentWithBoundary = () => {
return (
<ErrorBoundary>
<Component />
</ErrorBoundary>
)
}
Общей рекомендацией для ошибок такого рода является использование обычных try/catch
. По крайней мере здесь мы можем более или менее безопасно использовать состояние: обратные вызовы обработчиков событий — это как раз те места, где обычно устанавливают состояние. Итак, технически можно просто объединить два подхода, например:
const Component = () => {
const [hasError, setHasError] = useState(false);
// большинство ошибок в этом и в дочерних компонентах будут перехвачены ErrorBoundary
const onClick = () => {
try {
// эта ошибка будет поймана catch
throw new Error('Hulk smash!');
} catch(e) {
setHasError(true);
}
}
if (hasError) return 'something went wrong';
return <button onClick={onClick}>click me</button>
}
const ComponentWithBoundary = () => {
return (
<ErrorBoundary fallback={"Oh no! Something went wrong"}>
<Component />
</ErrorBoundary>
)
}
Мы вернулись к исходной ситуации: каждый компонент должен поддерживать свое состояние «ошибка» и, что более важно, принимать решение о том, что с ним делать.
Конечно, вместо того чтобы обрабатывать эти ошибки на уровне компонентов, можно просто передавать их до родителя, у которого есть ErrorBoundary
, через пропсы или Context
. Таким образом, по крайней мере можно иметь «резервный» компонент только в одном месте:
const Component = ({ onError }) => {
const onClick = () => {
try {
throw new Error('Hulk smash!');
} catch(e) {
// просто вызовите пропс вместо сохранения здесь состояния
onError();
}
}
return <button onClick={onClick}>click me</button>
}
const ComponentWithBoundary = () => {
const [hasError, setHasError] = useState();
const fallback = "Oh no! Something went wrong";
if (hasError) return fallback;
return (
<ErrorBoundary fallback={fallback}>
<Component onError={() => setHasError(true)} />
</ErrorBoundary>
)
}
Но здесь много дополнительного кода! Так пришлось бы делать для каждого дочернего компонента в дереве рендеринга. Не говоря уже о том, что сейчас мы обрабатываем два состояния ошибки: в родительском компоненте и в самом ErrorBoundary
. А у ErrorBoundary
уже есть все механизмы для распространения ошибок вверх по дереву — здесь мы делаем двойную работу.
Разве нельзя просто перехватывать эти ошибки из асинхронного кода и обработчиков событий с помощью ErrorBoundary
?
Поиск асинхронных ошибок с помощью ErrorBoundary
Хитрость заключается в том, чтобы сначала поймать ошибки с помощью try/catch
, затем внутри оператора catch
запустить обычную повторную визуализацию React, а затем повторно отбросить эти ошибки обратно в жизненный цикл повторной визуализации. Таким образом, ErrorBoundary
может перехватывать их, как и любую другую ошибку. И поскольку обновление состояния — это способ запуска повторного рендеринга, а функция установки состояния может фактически принимать функцию обновления в качестве аргумента, решение — чистая магия.
const Component = () => {
// создать случайное состояние, которое будем использовать для выдачи ошибок
const [state, setState] = useState();
const onClick = () => {
try {
// возникла какая-то проблема
} catch (e) {
// обновление состояния триггера с функцией обновления в качестве аргумента
setState(() => {
// повторно выдать эту ошибку в функции обновления
// будет запущено во время обновления состояния
throw e;
})
}
}
}
Полный пример в этом codesandbox.
Последним шагом будет абстрагирование этого сокращения, поэтому нам не нужно создавать случайные состояния в каждом компоненте. Здесь можно проявить творческий подход и создать хук, который создаст генератор асинхронных ошибок:
const useThrowAsyncError = () => {
const [state, setState] = useState();
return (error) => {
setState(() => throw error)
}
}
Используем так:
const Component = () => {
const throwAsyncError = useThrowAsyncError();
useEffect(() => {
fetch('/bla').then().catch((e) => {
// выдать асинхронную ошибку здесь
throwAsyncError(e)
})
})
}
Или можно создать оболочку для обратных вызовов следующим образом:
const useCallbackWithErrorHandling = (callback) => {
const [state, setState] = useState();
return (...args) => {
try {
callback(...args);
} catch(e) {
setState(() => throw e);
}
}
}
Используем так:
const Component = () => {
const onClick = () => {
// выполнить что-либо опасное здесь
}
const onClickWithErrorHandler = useCallbackWithErrorHandling(onClick);
return <button onClick={onClickWithErrorHandler}>click me!</button>
}
Или что-нибудь еще, что душе угодно и требуется приложению. Ошибки теперь не спрячутся.
Полный пример в этом codesandbox.
Можно ли использовать react-error-boundary?
Для тех, кто не любит изобретать велосипед или просто предпочитает библиотеки для уже решенных задач, есть хороший вариант, который реализует гибкий компонент ErrorBoundary
и имеет несколько полезных утилит, подобных описанным выше. Это — react-error-boundary.
Использовать его или нет — вопрос личных предпочтений, стиля программирования и уникальных особенностей компонентов.
Теперь, если в приложении возникнет проблема, вы сможете легко с ней справиться.
И запомните:
- Блоки
try/catch
не будут перехватывать ошибки внутри хуков, таких какuseEffect
, и внутри любых дочерних компонентов. ErrorBoundary
их перехватывать может, но не работает с ошибками в асинхронном коде и в обработчиках событий.- Тем не менее вы можете заставить
ErrorBoundary
ловить их. Просто сначала их нужно поймать с помощьюtry/catch
, а затем забросить обратно в жизненный цикл React.
Читайте также:
- Управление состоянием в React: обзор
- 9 советов по работе с консолью JavaScript, которые помогут оптимизировать отладку
- Preact вместо ручной оптимизации React-приложения
Читайте нас в Telegram, VK и Дзен
Перевод статьи Nadia Makarevich: How to handle errors in React: full guide