📝

「The above error occurred in the <*> component」で少し困った

2022/12/29に公開

概要

Next.js と Recoil で、atom の state と、それが変更されると値が変わる selector の両方を表示するコンポーネントを実装していたところ、The above error occurred in the <*> componentというエラーと遭遇しました。エラーの原因は、selector の更新の処理が非同期処理になっていたことでした。お恥ずかしながら問題の解決までまあまあ時間がかかりましたので、解決までの過程を備忘録として記述します。

結論

ErrorBoundary というコンポーネント (react-error-boundary というライブラリ) と、Suspense という React 標準のコンポーネントを用いて解決できました。
といっても、レンダリングが失敗しているコンポーネントを上記 2 つのコンポーネントで挟むだけです。結果的にはSuspenseタグで囲むだけでも大丈夫です。

<ErrorBoundary FallbackComponent={ErrorFallback}>
  <Suspense fallback={<div>...Loading</div>}>
    {/* エラーが発生したコンポーネント */}
    <SomeComponent />
  </Suspense>
</ErrorBoundary>

これ以降は、少し回りくどいかもしれませんが、

  1. 上記の<SomeComponent>に当たる部分 (あるいはその子孫のコンポーネント) を<Suspense>で囲む必要がない場合
  2. 上記の<SomeComponent>に当たる部分 (あるいはその子孫のコンポーネント) を<Suspense>で囲まないとエラーが出る (レンダリングが失敗する) 場合
  3. 「2.」のエラーを解決するために自分がやったこと

の 3 つについて順に記述していきます。

1. <SomeComponent>に当たる部分 (あるいはその子孫のコンポーネント) を<Suspense>で囲む必要がない場合

開発時となるべく同じ状況を再現するために、TypeScript と Recoil を用いて、簡単なサンプルを実装します。

src/pages ディレクトリ配下の、ExampleNextPage

src/pages/example/index.tsx
import { Example } from "../../components/examplePage";

const ExampleNextPage = () => {
  return <Example />;
};

export default ExampleNextPage;

Example コンポーネント

src/components/example.tsx
import { useRecoilValue } from "recoil";
import { TestState } from "../../globalStates/atoms/testState";
import { testSelector } from "../../globalStates/atoms/testState/selector/testStateSelector";
import { useSetTestState } from "../../globalStates/atoms/testState/useSetTestState";

export const Example = (): JSX.Element => {
  const testState = useRecoilValue(TestState);
  const testStateSelector = useRecoilValue(testSelector);
  const changeTestState = useSetTestState();
  return (
    <>
      <div>testState: {testState}</div>
      <button onClick={changeTestState}></button>
      <div>testSelector: {testStateSelector}</div>
    </>
  );
};

TestState の定義

src/globalStates/atoms/testState/index.ts
import { atom, RecoilState } from "recoil";

export const initialState: number = 0;

export const TestState: RecoilState<number> = atom({
  key: "testState",
  default: initialState,
});

TestState の更新用関数

src/globalStates/atoms/testState/useSetTestState.ts
import { useSetRecoilState } from "recoil";
import { TestState } from ".";

export const useSetTestState = () => {
  const setTestState = useSetRecoilState(TestState);
  const changeTestState = () => {
    setTestState((prev) => prev + 1);
  };
  return changeTestState;
};

testSelector (TestState の 2 倍の値を常に返す)

src/globalStates/atoms/testState/selector/testStateSelector.ts
import { selector } from "recoil";
import { TestState } from "..";

export const testSelector = selector({
  key: "testSelector",
  get: ({ get }) => {
    const testState = get(TestState);
    return testState * 2;
  },
});

長々と実装例を羅列してしまいましたが、上記の selector (testSelector の get プロパティ) の実装だと、Example コンポーネントのtestState<button> を押した後のtestStateSelectorも問題なく表示されます。

2. <SomeComponent>に当たる部分 (あるいはその子孫のコンポーネント) を<Suspense>で囲まないとエラーが出る (レンダリングが失敗する) 場合

testSelector の get プロパティの value に当たる関数が非同期関数である (Promise を return する) 場合、<Suspense>で囲まないとエラーが生じます。

つまり、↓ のように async, await を用いて get プロパティの関数内で外部 API を呼び出そうとした場合、

src/globalStates/atoms/testState/selector/testStateSelector.ts
import axios from "axios";
import { selector } from "recoil";
import { TestState } from "..";

export const testSelector = selector({
  key: "testSelector",
  get: async ({ get }) => {
    const testState = get(TestState);
    if (testState === 0) return testState;

    //以下では、PokeAPIを使用して、testStateと同じ値の図鑑番号のポケモン情報を取得し、そのポケモンの名前をreturnしています。趣味です。お許しください。
    const apiRes = await axios
      .get(`https://pokeapi.co/api/v2/pokemon/${testState}`)
      .catch((err) => {
        throw new Error(err);
      });

    return apiRes.data.name;
  },
});

<button> をクリックすると、おそらく画面が真っ暗になるとともに、ディベロッパーツールのコンソール上に、
Warning: Can't perform a React state update on a component that hasn't mounted yet. This indicates that you have a side-effect in your render function that asynchronously later calls tries to update the component. Move this work to useEffect instead.
(state の更新関数を後で非同期に呼び出してコンポーネントを更新しようとしているから、render 関数が副作用を持っている状態ですよ。的な意味)
という Warning と、
The above error occurred in the <Example> component:
というエラーが出るのではないかと思います。
※Next もしくは React のバージョンによっては Warning は出たり出なかったりするというのを聞いたことがあります(ここはちゃんと調べてはないです。すみません。)。ちなみに私の環境は、"react": "18.2.0", "next": "12.3.0"でした。

Warning と error の内容を踏まえると、Example コンポーネントのレンダリング中に副作用が生じていることが問題だと考えられます。

後で Suspense について記述する際にも紹介するのですが、この Warning を自分の事象と絡めて理解するにあたり、以下の記事が非常に参考になりました。
https://zenn.dev/uhyo/books/react-concurrent-handson/viewer/data-fetching-1
自分の場合、

button をクリック

testState を更新

testStateSelector の更新を試みようと非同期処理関数が登録される

Example コンポーネントがレンダリングされようとするが、前段階の Promise が解決されていないためサスペンドする

testStateSelector の更新関数の非同期処理が終了し testStateSelector を更新しようとするが、そもそも Example コンポーネントのレンダリングが失敗しているため、Example コンポーネントに描画されているはずの testStateSelector の更新も失敗する。

というような流れになっていたのではないかと推測しています。
※間違っていたら是非ご指摘お願いいたします 🙇‍♂️

ここで、「testStateSelector の取得関数が非同期だから testStateSelector を含む要素を<Suspense>で wrap すればいいよね」となれば話は早いのですが、私の場合知識不足で、今回の Warning と error が出て「?」となったので、ここから色々と調べました。

ここからは ErrorBoundary と Suspense を用いて解決できたので (やっと本題に入っている感じで申し訳ありませんが)、ErrorBoundary と Suspense の用途についてそれぞれ記述していきます。

3. 「2.」のエラーを解決するために自分がやったこと

ErrorBoundary で、レンダリングが失敗しているコンポーネントを囲む

ErrorBoundary:
https://ja.reactjs.org/docs/error-boundaries.html
<ErrorBoundary>の子コンポーネントツリー内で発生した JavaScript エラーをキャッチし、エラー内容を UI として表示くれるライブラリだそうです。私自身この便利そうなライブラリの存在を元々知らなかったので、今回出会えて結構嬉しかったです。

まずインストールしましょう。

https://www.npmjs.com/package/react-error-boundary

npm install react-error-boundary

今回は、Example コンポーネント内でエラーが生じているということだったので、ひとまず Example コンポーネント全体を <ErrorBoundary> で囲んでみます。

src/pages/example/index.tsx
import { ErrorBoundary } from "react-error-boundary";
import { ErrorFallback } from "../../components/errorFallBack";
import { Example } from "../../components/examplePage";

const ExampleNextPage = () => {
  return (
    <ErrorBoundary FallbackComponent={ErrorFallback}>
      <Example />
    </ErrorBoundary>
  );
};

export default ExampleNextPage;

FallbackComponentという属性のErrorFallbackという値 (コンポーネント) の中身に関しては、公式の「Usage」をがっつり参考にして実装しました。

https://github.com/bvaughn/react-error-boundary

src/components/errorFallBack.tsx
import { FallbackProps } from "react-error-boundary";

export const ErrorFallback = ({
  error,
  resetErrorBoundary,
}: FallbackProps): JSX.Element => {
  return (
    <div role="alert">
      <p>Error Message</p>
      <pre>{error!.message}</pre>
      <button onClick={resetErrorBoundary}>Try again</button>
    </div>
  );
};

この状態で、先ほどと同じように Example コンポーネント内の<button>をクリックすると、
Error Message A component suspended while responding to synchronous input. This will cause the UI to be replaced with a loading indicator. To fix, updates that suspend should be wrapped with startTransition. Try again
というメッセージが画面上に描画されるかと思います (ちなみに、Try againボタン (border がなくボタンぽい UI ではありませんが) を押すと、失敗していたレンダリングが成功した状態に遷移できます)。
エラーの内容としては、同期的な入力に応答している間にコンポーネントが中断しましたというような内容です (正しく訳せているか分かりませんが)。

私自身初見では、「コンポーネントが suspend (中断) している?」、「startTransition で wrap しろってどゆこと?」という感じだったので、ここからまた色々調べました。

<Suspense>でレンダリングが失敗しているコンポーネントを囲んでみた

結論としては、今回の場合 ↓ のように実装するだけで問題は解決できました。

src/pages/example/index.tsx
import { Suspense } from "react";
import { Example } from "../../components/pages/examplePage";

const ExampleNextPage = () => {
  return (
    <Suspense fallback={<div>...Loading</div>}>
      <Example />
    </Suspense>
  );
};

export default ExampleNextPage;

Suspense は、サスペンドしてレンダリングができない (例えば、読み込みやデータ取得に時間が必要な値を描画するような) コンポーネントを、「まだレンダリングできない (レンダリング結果がないために DOM に反映できない) 状態のコンポーネント」として処理してくれます。
<Suspense>タグで囲むと、子孫のコンポーネントのレンダリングが失敗している間は、fallback属性に指定した値 (上記の例で言うと<div>...Loading</div>) をレンダリングし、子孫コンポーネントがレンダリングできる (レンダリング結果を DOM に反映できる) 状態になったら、子孫コンポーネントをレンダリングしてくれるようになります。

私の説明だけでは自信がないので、Suspense を勉強する上でめちゃくちゃ丁寧で参考になった記事を添付させていただきます。
https://zenn.dev/uhyo/books/react-concurrent-handson/viewer/what-is-suspense

startTransitionについて

ErrorBoundary によって表示されたエラーメッセージ内で言及されていたstartTransitionについても少し触れておこうかと思います。
startTransition についても、Suspense で参考にさせていただいた記事の著者の方の ↓ の記事が非常に分かりやすくて参考になりました。
https://zenn.dev/uhyo/books/react-concurrent-handson-2/viewer/use-starttransition

startTransitionを Example コンポーネント内で ↓ の実装例のように使うと、例えばtestState (changeStateで更新される state) が Example コンポーネントの外で描画されているような場合でも、onClickが発火してから、Example コンポーネントとtestStateが描画されているコンポーネントの両方がサスペンドし、少し遅延してから求める UI が描画されます。

src/components/example.tsx
<button
  onClick={() =>
    startTransition(() => {
      changeTestState;
    })
  }
></button>

今回は Example コンポーネントの外まで考慮する必要がなかったので、startTransitionは使用しませんでした。

最後に

この記事の中で記述している私自信の解釈などにはかなり自信がないため、間違っている点などありましたら、是非有識者の皆様のご指摘をいただきたいです。よろしくお願いいたします。🙇‍♂️

GitHubで編集を提案

Discussion