30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

Error Boundary で React アプリ内のエラーを捕捉する

Error Boundary は React のv16から導入された機能で、これを使うとコンポーネント内で発生したエラーをキャッチすることが出来る。

主に、エラー用のUIを表示したり、エラーを記録したりすることに使われる。
前者にはstatic getDerivedStateFromErrorというメソッドを、後者にはcomponentDidCatchというメソッドを用いる。
Error Boundary のためのクラスコンポーネントを作り、そこにこれらのメソッドを定義して使う。

この記事では、バージョン16.8.6で動作確認している。

動作確認用のアプリの用意

以下の内容でアプリを作る。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Template htmlfile</title>
</head>
<body>
  <p>↓この下に React アプリが表示される↓</p>
  <div id="app"></div>
  <p>↑この上に React アプリが表示される↑</p>
</body>
</html>
import React from 'react';
import ReactDOM from 'react-dom';

const AquaChild = () => (
  <div style={{backgroundColor: 'white'}}>aqua child</div>
);

const Aqua = () => (
  <div style={{backgroundColor: 'aqua', padding: '10px'}}>
    aqua
    <AquaChild />
  </div>
);
const Lime = () => <div style={{backgroundColor: 'lime'}}>lime</div>;

const App = () => (
  <>
    <Aqua />
    <Lime />
  </>
);

ReactDOM.render(<App />, document.querySelector('#app'));

これをビルドすると以下のようなUIを持ったアプリが出来るので、これを対象に検証していく。

f:id:numb_86:20190615221816p:plain

エラー用のUIを表示させる

React はv16から、発生したエラーがキャッチされなかった場合、コンポーネントツリー全体をアンマウントするようになった。
公式ドキュメントによればこれは、壊れたUIを表示することは何も表示しないことよりも悪いことである、という考えによるもの。

試しに、先程作ったサンプルでエラーを発生させてみる。

 const Aqua = () => (
   <div style={{backgroundColor: 'aqua', padding: '10px'}}>
-    aqua
+    aqua{x}
     <AquaChild />
   </div>
 );

Aquaのなかでxを参照しているが、xは存在しない変数なのでReferenceErrorが発生する。
この結果、アプリ全体が表示されなくなる。

f:id:numb_86:20190615221856p:plain

エラーが発生したAquaだけでなく、Limeも含めたコンポーネントツリー全体がアンマウントされているのが分かる。

static getDerivedStateFromErrorを使ってエラーをキャッチすることで、ツリー全体がアンマウントされるのを防ぎ、適切なUIを表示させることが出来る。

Error Boundary を使うため、以下のコンポーネントを作成した。

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = {hasError: false};
  }

  static getDerivedStateFromError(error) {
    console.log(error instanceof Error);
    return {hasError: true};
  }

  render() {
    const {state, props} = this;
    if (state.hasError) {
      return (
        <div style={{backgroundColor: 'orange'}}>
          背景がオレンジのコンポーネントは ErrorBoundary
          によって表示されているエラー用のコンポーネントです。
        </div>
      );
    }
    return props.children;
  }
}

static getDerivedStateFromErrorの引数で、キャッチしたエラーオブジェクトを取得できる。

そしてこのコンポーネントで、AquaLimeをそれぞれラップする。

 const App = () => (
   <>
-    <Aqua />
-    <Lime />
+    <ErrorBoundary>
+      <Aqua />
+    </ErrorBoundary>
+    <ErrorBoundary>
+      <Lime />
+    </ErrorBoundary>
   </>
 );

こうすることで、AquaLimeのなかでエラーが発生した際に、それをキャッチできる。

この状態でページを表示すると、今度は以下のようになっている。

f:id:numb_86:20190615221920p:plain

Aquaで発生したエラーをキャッチしてエラー用のUIが表示されている。
その一方で、Limeではエラーが発生していないので、そのまま表示されている。

このように、Error Boundary を上手く使うことでアプリ全体がクラッシュするのを防ぎ、適切なUIをユーザーに提供できるようになる。

Error Boundary の直下ではなくもっと深い階層でエラーが発生しても、問題なくキャッチできる。

 const AquaChild = () => (
-  <div style={{backgroundColor: 'white'}}>aqua child</div>
+  <div style={{backgroundColor: 'white'}}>aqua child{x}</div>
 );
 
 const Aqua = () => (
   <div style={{backgroundColor: 'aqua', padding: '10px'}}>
-    aqua{x}
+    aqua
     <AquaChild />
   </div>
 );

AquaではなくAquaChildでエラーを発生させても、先程と同じ表示になる。

f:id:numb_86:20190615221920p:plain

コンポーネントのなかでエラーが発生すると、ツリーを上に辿っていき、一番最初に到達した ErrorBoundary がエラーをキャッチする仕組みになっている。つまり、JavaScript のcatch{}と同じような挙動である。
最後までキャッチされなかった場合は、既に述べたようにツリー全体がアンマウントされる。

Error Boundary の対象になるのは、配下のコンポーネントで発生したエラーのみ。
Error Boundary 自身のなかで発生したエラーは、キャッチすることが出来ない。
例えば以下のようにすると、キャッチできずにツリー全体がアンマウントされてしまう。

 const AquaChild = () => (
-  <div style={{backgroundColor: 'white'}}>aqua child{x}</div>
+  <div style={{backgroundColor: 'white'}}>aqua child</div>
 );

const App = () => (
   <>
     <ErrorBoundary>
       <Aqua />
+      <div>{x}</div>
     </ErrorBoundary>
     <ErrorBoundar

このエラーをキャッチしたければ、Appをラップする必要がある。

-ReactDOM.render(<App />, document.querySelector('#app'));
+ReactDOM.render(
+  <ErrorBoundary>
+    <App />
+  </ErrorBoundary>,
+  document.querySelector('#app')
+);

そうするとAppの代わりにエラー用のUIが表示されるようになる。

f:id:numb_86:20190615221954p:plain

エラーを記録する

エラーをログに残したり、エラー監視サービスなどに送信したりする場合は、Error Boundary にcomponentDidCatchメソッドを定義して、そのなかで行う。

     return {hasError: true};
   }
 
+  componentDidCatch(error, info) {
+    console.log(error);
+    console.log(info.componentStack);
+  }
+
   render() {
     const {state, props} =

第一引数で、キャッチしたエラーオブジェクトを取得できる。
第二引数のinfocomponentStackを持っており、ここには、エラーが発生したコンポーネントのスタックトレースが入っている。

以下は、AquaChildでエラーが発生した際のスタックトレース。

    in AquaChild (at src/index.js:13)
    in div (at src/index.js:11)
    in Aqua (at src/index.js:21)
    in ErrorBoundary (at src/index.js:20)
    in App (at src/index.js:29)

static getDerivedStateFromErrorとの違いだが、公式のAPIリファレンスによれば、static getDerivedStateFromErrorはUIの描画のために使い、副作用を扱う場合にcomponentDidCatchを使うとよいらしい。

キャッチできないエラー

既に述べたように Error Boundary は自身のエラーをキャッチすることが出来ないが、配下のコンポーネントのエラーでもキャッチしないものが2つある。

1つ目は、イベントハンドラ内でのエラー。
以下のようにボタンを押した際にエラーが発生するようにした場合、レンダリング時にはエラーを出さないので、そのまま表示される。

   <div style={{backgroundColor: 'aqua', padding: '10px'}}>
     aqua
     <AquaChild />
+    <button
+      type="button"
+      onClick={() => {
+        x;
+      }}
+    >
+      error button
+    </button>
   </div>

f:id:numb_86:20190615222013p:plain

そしてボタンを押すとエラーが発生するのだが、このエラーはキャッチされず、表示にも影響がない。

2つ目が、非同期処理のなかでのエラー。これも、エラーはどこにもキャッチされないまま終わり、アプリはそのまま表示され続ける。

const Aqua = () => {
  Promise.resolve().then(() => x);
  return (
    <div style={{backgroundColor: 'aqua', padding: '10px'}}>
      aqua
      <AquaChild />
    </div>
  );
};

参考資料

『オブジェクト指向設計実践ガイド ~Rubyでわかる 進化しつづける柔軟なアプリケーションの育て方』を読んだ

SPAをフルスクラッチで作ることになり、設計に対して関心が高まっていた。いきなりドメイン駆動設計は厳しいしそもそもオブジェクト指向についてよく分かっていないので、ネット上で評判がよかった本書を読んだ。
サンプルが Ruby で書かれているのも、選んだ理由。これなら読めると思った。

gihyo.jp

非常に読みやすく、よかった。
特に、「設計」に対する変な苦手意識や劣等感を払拭すると同時に、新たな視点を得ることが出来たのがよかった。

「設計」や「オブジェクト指向」というのは、今の自分には理解できないような高尚な目的のために、自分には理解できない高度な何かを行っているものだと、思っていた。
だがそうではなかった。

まず目的。
なぜオブジェクト指向で設計するのか。
それは、「変更に強いプログラムを作る」という、きわめて実利的でシンプルな目的のため。
ちなみに、最近自分が関心を持っているドメイン駆動設計についても、その目的は「変更しやすいソフトウェアを作ること」にあるらしい。
当たり前の話ではあるが、オブジェクト指向設計もドメイン駆動設計も、それ自体は目的ではなく、あくまでも手段に過ぎない。
この認識を持てたのは大きい。今後どんなに難しい話が出てきても、土台にあるのは、「変更を簡単にする」というシンプルで自分にも共感できる価値。
ともすれば高名な設計を取り入れること自体が目的になってしまうが、そうではない。

そしてやっている内容についても、自分とそこまで距離を感じなかった。
単一責任は普段から意識しているし、依存性の注入にしろダックタイピングにしろ、これまでに何度も実践してきた。ただそれらを、そういった用語で呼んでいなかっただけで。
もちろん見落とすことはよくあるし、様々な制約によって理想を実装に上手く落とし込めないこともある。
それに、例えばダックタイピングなんかは、意識してそれを使っていたというより、よりよいコードを求めて改善を重ねていたら結果的にそうなったということが多い。ダックタイピングという概念を知っていることで、最初からそれを選択肢として持つことが出来る。これは他の設計パターンやテクニックにも言える。
だが、「オブジェクト指向」や「設計」はそこまで自分から遠いものではないと感じたのも事実だ。どこまで自覚的か、どこまで徹底できているかはともかく、普段からやっていることではある。本書が入門書だからなのだろうが、「設計」に対する敷居の高さを緩和することが出来た。

これは俺にセンスがあるから、ではなく、職業プログラマになりたての時に優秀なメンターに色々と教えてもらったからだと思う。
numb86-tech.hatenablog.com

そして、これが最大の収穫だが、設計に対して新たな視点を得ることが出来た。
今まで、どこかに「正解」があると思っていた。「すごいプログラマ」は一瞬でそれを見抜き実装するのだと。
だがそうではなかった。「完璧な設計」がどこかにあるのだと思っていたが、そうではなかった。
そんなものは存在しない。

そもそも、設計が終わることはない。設計は変化していく。
そして、変化が前提になっている。まず完璧なものを作って定期的に変更する、ではなく、最初から変化を前提にしている。
だから、完璧な設計はどこにも存在し得ない。設計は常に変化の「途中」である。

そして、だからこそ、変更容易性というものが非常に重要になってくる。アプリケーションは変化していくものであり、それゆえに、変更に強いものでなければならない。

何を作るべきか、というのは思っているよりも自明ではない。それを知るための情報は不足しているし、自分自身の能力不足によって先を見通せないこともある。状況も常に動いていく。
ゴールは変化していく。だから、設計が「完璧」に到達することも決してない。

そんなことを考えていたら、まさに「あとがき」にこう書かれていた。

アプリケーションが完璧になることはありませんが、くじけてはいけません。完璧さというのは捉えがたく、おそらく到達もできないでしょう。

そして、本書の序盤に書かれてある「設計は漸進的なプロセス」「設計とは、アプリケーションの可変性を保つために技巧を凝らすことであり、完璧を目指すための行為ではない」といった言葉の意味を、理解することが出来た。

「正解」や「完璧」などないのだ、という視点を得られたのは大きい。
設計は漸進的な行為であるという視点を得られたことで、設計やプログラミングに対する考え方が大きく変わる気がしている。