React で side effect のエラーハンドリング

この記事は [CodeChrysalis Advent Calendar 2019](https://qiita.com/advent-calendar/2019/codechrysalis) の記事です。

# はじめに

[Reactjs](https://reactjs.org/)には[Error Boundaries](https://reactjs.org/docs/error-boundaries.html#introducing-error-boundaries)という、エラーをcatchしたときに専用のComponentをrenderしてくれる機能がありますが、これはrender時におけるエラーのみcatchする機能です。

`componentDidCatch`というライフサイクルが用意されていて、これを以てサードパーティのエラー監視パッケージに通知するようにと推奨されています。

ですが多くの場合、フロントエンドはバックエンドと通信する等のside effectを持ちます。

1. side effectを用いたときのエラーハンドリングが必要となる
2. side effectを用いたときもエラーメッセージを表示するComponentを共通化したい
3. side effectを用いたときもサードパーティのエラー監視パッケージ（Sentryなど）に通知したい

という要件に対してのエラーハンドリング専用のComponentを考えました。

前提として、[React Hooks](https://reactjs.org/docs/hooks-intro.html)を使います。

## 設計の内容

エラーが発生したときに[Context](https://reactjs.org/docs/context.html)を使ってエラーの情報を保存し、専用Componentでそれらの情報を使うようにしました。

## コードの内容

### Context

```tsx

import React, { useState, useContext, createContext } from 'react';

const ErrorContext = createContext({
  hasError: false,
  userMessage: null,
  error: null,
  setContextError: (userMessage: string, error: Error) => {},
  setCotextErrorDone: () => {},
});

export const ErrorProvider: React.FC<object> = props => {
  const [hasError, setHasError] = useState<boolean>(false);
  const [userMessage, setUserMessage] = useState<string | null>(null);
  const [error, setError] = useState<Error | null>(null);

  const setContextError = (userMessage: string, error: Error) => {
    setUserMessage(userMessage);
    setError(error);
    setHasError(true);
  };

  const setCotextErrorDone = () => {
    setUserMessage(null);
    setError(null);
    setHasError(false);
  };

  return (
    <ErrorContext.Provider
      value={{ hasError, userMessage, error, setContextError, setCotextErrorDone }}
      {...props}
    />
  );
};

export const useError = () => {
  const context = useContext(ErrorContext);

  if (context === undefined) {
    throw new Error(`useError must be used within a ErrorProvider`);
  }
  return context;
};
```

#### 要素の説明

* `hasError` はこれが`true`になっているとErrorハンドリングのComponentをrenderするようにするためのstateです。
* `userMessage` はエラーが発生した箇所でエラー内容が特定できるものに関してはその場でこのstateにエラーメッセージを保管し、ErrorハンドリングのComponentをrenderするときに使えるようにしています。
* `error` はエラーが発生した箇所の`trycatch`で取得した`error`インスタンスそのものを入れています。`Sentry`に送るためのものです。
* `setContextError`でエラー発生時にエラー内容を保存し、`hasError`のstateを変更します。
* `setContextErrorDone`で、ErrorハンドリングのComponentからどこかに遷移するときに`hasError`のstateを`false`に変更するようにしています。


```tsx

import React, { useState } from 'react';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import * as Sentry from '@sentry/browser';
import { Header } from 'components';
import { useUser } from 'context/UserContext';
import { useError } from 'context/ErrorContext';
import styles from './ErrorBoundary.module.css';

const ErrorBoundary: React.FC<RouteComponentProps> = ({ history: { push } }) => {
  const { userId } = useUser();
  const { hasError, userMessage, error, setCotextErrorDone } = useError();

  const [eventId, setEventId] = useState<string | null>(null);

  const handleHelp = () => {
    setCotextErrorDone();
    setEventId(null);

    push({ pathname: '/somewhere' });
  };

  if (hasError && !eventId) {
    Sentry.withScope(scope => {
      if (attendanceId) {
        scope.setUser({ id: userId });
      }
      scope.setExtras({ userMessage });
      scope.setTag('errorCategory', 'Side Effect');
      const currentEventId = Sentry.captureException(error);
      setEventId(currentEventId);
    });
  }

  let title = 'エラーが発生しました';

  if (hasError) {
    return (
      <aside className={styles.mainContainer}>
        <Header />
        <h1 className={styles.mainHeader}>{title}</h1>
        <h2 className={styles.subHeader}>
          <span>{userMessage}</span>
        </h2>
        <button className={styles.inquiryButton} onClick={handleHelp}>ヘルプ</button>
      </aside>
    );
  }
  return null;
};

export default withRouter(ErrorBoundary);
```

#### 要素の説明

* ErrorハンドリングのComponentを作成することで、Error時に表示するUIもカスタマイズで作成できます。
* このComponentがrenderされたらSentryにメッセージを通知するようにしています。以下のコードの部分です。

```js
    Sentry.withScope(scope => {
      if (attendanceId) {
        scope.setUser({ id: userId });
      }
      scope.setExtras({ userMessage });
      scope.setTag('errorCategory', 'Side Effect');
      const currentEventId = Sentry.captureException(error);
      setEventId(currentEventId);
    });
```

* ヘルプボタンを押すと`handleHelp`を実行して、`setContextErrorDone`が実行され、エラーに関するContextがクリアされると同時にどこかのURIに移動するようにしています。
* 別のContextである`userUser`から`userId`を取得して、Sentryに送信し、もし問い合わせが来たらSentry上のエラーメッセージと関連付けて調査できるようにしています（Sentry便利！）
* `let title = ...` の部分はもしErrorの内容によってタイトルを変えたいときのために`let`にしています。

### Sentry

Sentryの設定で、Slackに送信するように連携しておけばこれらのメッセージをSlackに送信できます。

また、

```js
scope.setUser({ id: userId });
```

これは任意のユーザー属性を割り当てることができます。

```js
scope.setExtras({ userMessage });
```

これは補足情報を割り当てることができます。ここではユーザーにどのような情報を表示しているのかを把握するために割り当てています。

```js
scope.setTag('errorCategory', 'Side Effect');
```

Sentryの画面にタグを表示できるので、React純粋のError BoundaryとこのカスタムのBoundaryを切り分けています。前者はrenderのエラー、後者はSide Effectで十中八九バックエンドか通信周りが関係していると悟ります。

# さいごに

もしより良いエラーハンドリングの構造があればぜひ教えてください！