はじめに
Reactでは、コンポーネントがエラーを発生させた場合はErrorBoundaryで、読み込みが完了するまではSuspenseで、それぞれフォールバックコンポーネントを指定できます。
import { type FC, Suspense } from 'react';
import { ErrorBoundary } from "react-error-boundary";
const SampleComponent: FC = () => {
return (
// ChildComponentでエラーがスローされるとErrorFallbackが代わりに表示される
<ErrorBoundary fallback={<ErrorFallback />}>
{/* ChildComponentで読み込みが完了するまでLoadingFallbackが代わりに表示される */}
<Suspense fallback={<LoadingFallback />}>
<ChildComponent />
</Suspense>
</ErrorBoundary>
);
};
ErrorBoundaryはReactから提供されるコンポーネントではありません。自分で実装するか、react-error-boundary等のライブラリから提供されるものを使う必要があります。
一方、SuspenseはReactから提供されるコンポーネントです。Next.jsのようなフレームワーク上のデータフェッチや、TanStack QueryのuseSuspenseQuery、Reactのuseとlazy関数のようなSuspenseに対応した特定の読み込み方法に対してだけSuspenseは有効になります。
このようにコンポーネント間に境界を設ける仕組みでは、境界の配置場所やフォールバックコンポーネントの記述位置を決めるのが難しいです。
この記事では、フォールバックコンポーネントの記述位置について、いくつかのパターンとその使い分けを整理します。
ErrorBoundaryとSuspenseの性質
ErrorBoundaryやSuspenseは、子コンポーネントが持つ特定の状態(エラーや読み込み中)に対して代わりの見た目を提供するコンポーネントです。そのため、フォールバックとして何を表示するかは、子コンポーネントの見た目や振る舞いと密接に関わります。
しかし、ErrorBoundaryやSuspenseをどこに配置するかは、子コンポーネントではなく親コンポーネントの都合で決まります。
たとえば、リスト内の各アイテムが個別にローディング表示されるよりも、リスト全体で1つのローディング状態を見せたいこともあるでしょう。親が細かい粒度のフォールバックを好まない場合、アイテムごとのフォールバックが用意されていても、より広い範囲で境界を設ける判断をすることがあります。
このような性質を踏まえた上で、この記事ではフォールバックコンポーネントを呼び出し側に配置するパターンと対象のコンポーネントと一緒に配置するパターンの2つについて考察します。
呼び出し側で配置する
1つ目は呼び出し側で配置する、すなわち親コンポーネントで定義する方法です。
const ErrorFallback: FC = () => {
return ...;
};
const LoadingFallback: FC = () => {
return ...;
};
const SampleComponent: FC = () => {
return (
<ErrorBoundary fallback={<ErrorFallback />}>
<Suspense fallback={<LoadingFallback />}>
<ChildComponent />
</Suspense>
</ErrorBoundary>
);
};
この方法は、フォールバックの内容を親の文脈に合わせて自由に決められます。
そのため、ChildComponentの見た目や振る舞いと密接に関わるフォールバックを提供したい場合には向いていません。ChildComponentのフォールバックを定義するたびに、フォールバックを定義し直す必要があります。
これは単純に手間がかかるだけでなく、利用する場所によってフォールバックの見た目がばらつくリスクもあります。
この方法が向いているのは、複数のコンポーネントをまとめて1つの境界で囲むケースです。
const SampleComponent: FC = () => {
return (
<Card>
<ErrorBoundary fallback={<ErrorFallback />}>
<Suspense fallback={<LoadingFallback />}>
<Image />
<Description />
</Suspense>
</ErrorBoundary>
</Card>
);
};
この例では、ImageとDescriptionを子コンポーネントとして1つの境界でまとめています。フォールバックは特定のコンポーネントに紐づくものではなく、Cardというコンテキストに合わせています。このような場合、子コンポーネントの見た目や振る舞いを親側でまとめているので、フォールバックも親側で定義するのが自然です。
対象のコンポーネントと一緒に定義する
2つ目は、対象のコンポーネントと同じファイルでフォールバックを定義する方法です。こちらにはいくつかのバリエーションがあります。
すべてexportする
ChildComponentsと同じファイルで、ErrorFallbackとLoadingFallbackの両方をexportする方法です。
export const ChildComponentErrorFallback: FC = () => {
return ...;
};
export const ChildComponentLoadingFallback: FC = () => {
return ...;
};
export const ChildComponent: FC = () => {
return (
...
);
};
呼び出し側はErrorBoundaryやSuspenseの配置を自由に決めながら、ChildComponentに適したフォールバックをそのまま使えます。
ただし、この方法では各コンポーネントにフォールバックが用意されているかどうかを知るには、ファイルの中身を覗くか、importの補完を確認するしかありません。
コンポーネント内部で境界を設ける
コンポーネント自身でErrorBoundaryやSuspenseを持ち、フォールバックも内部で完結させる方法です。
const ErrorFallback: FC = () => {
return ...;
};
const LoadingFallback: FC = () => {
return ...;
};
const _ChildComponent: FC = () => {
return (
...
);
};
export const ChildComponent: FC = () => {
return (
<ErrorBoundary fallback={<ErrorFallback />}>
<Suspense fallback={<LoadingFallback />}>
<_ChildComponent />
</Suspense>
</ErrorBoundary>
);
};
呼び出し側は境界やフォールバックを意識する必要がなくなります。ただし、「ErrorBoundaryとSuspenseの性質」で述べたように、親がより広い範囲で境界を設けたい場合には、この方法だと柔軟性が失われます。
この方法は、フォールバックの仕様が強く固定されている場合に向いています。
対象のコンポーネントに紐づける
フォールバックをコンポーネントのプロパティとしてアタッチする方法です。
const ErrorFallback: FC = () => {
return ...;
};
const LoadingFallback: FC = () => {
return ...;
};
const _ChildComponent: FC = () => {
return (
...
);
};
export const ChildComponent = Object.assign(_ChildComponent, {
ErrorFallback,
LoadingFallback,
});
「すべてexportする」と似ていますが、ChildComponentをインポートした時点で、フォールバックが用意されているかどうかを型情報から読み取れるという利点があります。
const ChildComponent: FC & {
ErrorFallback: FC;
LoadingFallback: FC;
}
実際に利用するときは、ChildComponent.ErrorFallbackとChildComponent.LoadingFallbackのように呼び出します。
const SampleComponent: FC = () => {
return (
<ErrorBoundary fallback={<ChildComponent.ErrorFallback />}>
<Suspense fallback={<ChildComponent.LoadingFallback />}>
<ChildComponent />
</Suspense>
</ErrorBoundary>
);
};
RSCを利用する時の注意点
RSC(React Server Components)を利用している環境で、親コンポーネントがServer Component、子コンポーネントのファイルが'use client'でマークされている場合、以下のようなエラーが発生します。
Error: Element type is invalid: expected a string (for built-in components) or a class/function (for composite components) but got: undefined. You likely forgot to export your component from the file it's defined in, or you might have mixed up default and named imports.
これが発生する原因はコンポーネントに紐づけたプロパティがServer Component側から参照できないことにあります。
'use client'でマークされたファイルのコンポーネントをServer Componentからimportした場合、ChildComponent.ErrorFallbackのようなプロパティはundefinedになるからです。
例えば、ChildComponentにdisplayNameを定義しても、Server Componentから参照するとundefinedになります。
RSCを利用している場合、Object.assignでプロパティを紐づける処理は'use client'を付けていないファイルで行うようにしましょう。
// _ChildComponent.tsx
'use client';
export const ErrorFallback: FC = () => {
return ...;
};
export const LoadingFallback: FC = () => {
return ...;
};
export const _ChildComponent: FC = () => {
return (
...
);
};
// ChildComponent.tsx
export const ChildComponent = Object.assign(_ChildComponent, {
ErrorFallback,
LoadingFallback,
});
まとめ
フォールバックコンポーネントを配置する方法として、以下のパターンを紹介しました。
| パターン | 向いているケース |
|---|---|
| 呼び出し側で定義する | 複数のコンポーネントをまとめて境界で囲む場合や、親の文脈に合わせたフォールバックを表示したい場合 |
| コンポーネント内部で境界を設ける | フォールバックの見た目が仕様として固定されていて、呼び出し側に境界を意識させたくない場合 |
| 対象のコンポーネントに紐づける | 子コンポーネントに適したフォールバックを提供しつつ、型情報からフォールバックの存在を明示したい場合 |
「すべてexportする」と「対象のコンポーネントに紐づける」は同じケースで利用されます。後者は型情報からフォールバックの存在を読み取れるため、基本的には「対象のコンポーネントに紐づける」を使うのが良いと考えています。
どのパターンを選ぶかは、フォールバックの見た目を誰が決めるか、フォールバックが固定されているか、の観点で判断するとよいです。
- フォールバックの見た目を親が管理する →「呼び出し側で定義する」
- フォールバックの見た目を子が管理し、固定する →「コンポーネント内部で境界を設ける」
- フォールバックの見た目を子が管理するが、固定しない →「対象のコンポーネントに紐づける」
おわりに
この記事では、ErrorBoundary と Suspense のフォールバックコンポーネントをどこに配置するかについて整理しました。
結局のところ、銀の弾丸はなく、それぞれが最適な方法を場合によって探し出す必要があると考えています。まとめで挙げた観点以外にも、プロジェクトの規模やチームの方針によって最適解を探しましょう。
今回紹介したパターンのうち、個人的には「対象のコンポーネントに紐づける」が好みです。型情報からフォールバックの存在がわかるのは、コードを読む側にとって親切ですし、呼び出し側に境界の配置を委ねられる柔軟性も魅力的です。
このパターンを利用することを前提に、境界をもとにコンポーネントを分割することを考えることもあります。
他にも良いパターンがあれば、コメントで教えてもらえると嬉しいです。