ReactのSuspenseコンポーネントを理解する
概要
React18から正式に使えるようになったSuspenseという機能について、自身であまり積極的につかってこなかったため改めて、理解をまとめる。
公式ドキュメント
https://react.dev/reference/react/Suspense
整理
- コンポーネント内で処理が中断(例えばPromiseの実行待ち)が起きるようになった。
- コンポーネントはレンダリングを中断するという状態をもつようになった
- Suspenseコンポーネントのchildrenとして渡したコンポーネントが中断された場合、fallbackに渡したコンポーネントがレンダリングされる。
- childrenのコンポーネントの中断が解除されると、fallbackからchildrenに表示が自動的に切り替わる
使い方
<Suspense fallback={<Loading />}>
<Component />
</Suspense>
中断されるコンポーネントとはどんなものか
中断されるコンポーネントは、レンダリング中に「Promise を throw」する。
Promiseをthrowする とはどういう状態なのかというと、以下のように文字通り。Promiseインスタンスをthrow構文でスコープ外にスローする。
throw new Promise()
throw はエラー以外も投げられる
throw構文は、一般的にエラーに対する例外処理として使われ、頭がその作りだったため、「Promiseをthrowする」という設計について、理解が追いついていなかったが、Javascriptの構文上は、throwではエラーインスタンス以外のデータ型も使える。
よって当然、Promiseのインスタンスもthrowして、親コンポーネントで捉えることができる。
function throwNum() {
throw 1;
}
function throwString() {
throw "hoge";
}
function throwObj() {
throw {
type: "type",
data: "data"
};
}
function catchTest() {
try {
throwNum();
} catch (e) {
console.log(e);
}
try {
throwString();
} catch (e) {
console.log(e);
}
try {
throwObj();
} catch (e) {
console.log(e);
}
}
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/throw
Promiseをthrowするデータ取得例
import { Suspense } from "react";
let executingPromise: PromiseWrapper<any> | undefined;
type State<T> =
| {
type: "pending";
promise: Promise<T>;
}
| {
type: "fulfilled";
result: T;
}
| {
type: "rejected";
error: unknown;
};
// Promiseの状態によって、Error, Promiseを適切にthrowするためのラッパークラス
class PromiseWrapper<T> {
#state: State<T>;
constructor(promise: Promise<T>) {
const p = promise
.then((result) => {
this.#state = {
type: "fulfilled",
result: result
};
return result;
})
.catch((e) => {
this.#state = {
type: "rejected",
error: e
};
throw e;
});
this.#state = {
type: "pending",
promise: p
};
}
get(): T {
switch (this.#state.type) {
case "pending": {
throw this.#state.promise;
}
case "fulfilled": {
return this.#state.result;
}
case "rejected": {
throw this.#state.error;
}
}
}
}
// 実際の非同期データ処理
async function fetchTestData(): Promise<string> {
// テストのため3秒遅延
await new Promise((resolve) => setTimeout(resolve, 3000));
return "test data";
}
// データ取得処理
// Promiseの状態をみて、
// 処理中 => Promiseをthrowする
// 成功 => データを返す
// 失敗 => Errorをthrowする
function fetchData(): string {
if (!executingPromise) {
executingPromise = new PromiseWrapper(fetchTestData());
}
return executingPromise.get();
}
// 中断される可能性のあるコンポーネント
function SuspendedComponent() {
const data = fetchData();
return <div>{data}</div>;
}
export default function App() {
return (
<div className="App">
<Suspense fallback={"loading..."}>
<SuspendedComponent />
</Suspense>
</div>
);
}
https://codesandbox.io/s/suspensenodetaqu-de-li-ddxjdk?file=/src/App.tsx
まず、中断される可能性があるコンポーネントは SuspendedComponent
でこれは、Suspenseで囲われる。
function SuspendedComponent() {
const data = fetchData();
return <div>{data}</div>;
}
SuspendedComponentは、useEffectなどは使わず直接関数コンポーネントのなかでfetchDataを呼び出す。
これが可能なのは、fetchDataが非同期ではなく同期的関数のため。
async function fetchTestData(): Promise<string> {
// テストのため3秒遅延
await new Promise((resolve) => setTimeout(resolve, 3000));
return "test data";
}
しかし、実際のデータ取得関数は、Promiseを返す非同期関数となっている。これはAPIからデータ取得を行う多くのケースでそうだと思う。
この非同期関数から返されるPromiseを同期的関数に変換するための仕組みとして PromiseWrapper
というクラスが存在する。
class PromiseWrapper<T> {
...
get(): T {
switch (this.#state.type) {
// 未完了ならpromiseをthrow
case "pending": {
throw this.#state.promise;
}
// 完了済みならそのまま結果を返す
case "fulfilled": {
return this.#state.result;
}
// 失敗していたらエラーをthrow
case "rejected": {
throw this.#state.error;
}
}
}
}
このようなクラスでPromiseを保持しておき、同期的に取得可能な結果が得られない場合は、PromiseまたはErrorをthrowするという仕組みになっている。
こうすることで、fetchDataでは必ず同期的に結果が得られるようになり、もし非同期処理が終わっていない場合は、SuspendコンポーネントがthrowされたPromiseを引き続き監視し、fallbackのコンポーネントを表示してくれる。
https://codesandbox.io/s/s9zlw3?file=/Albums.js&utm_medium=sandpack
function use(promise) {
if (promise.status === 'fulfilled') {
return promise.value;
} else if (promise.status === 'rejected') {
throw promise.reason;
} else if (promise.status === 'pending') {
throw promise;
} else {
promise.status = 'pending';
promise.then(
result => {
promise.status = 'fulfilled';
promise.value = result;
},
reason => {
promise.status = 'rejected';
promise.reason = reason;
},
);
throw promise;
}
}
Reactの公式サンプルでは、useという関数で同様なことをしており、処理されるPromise自体はMapでキャッシュされている。
※ 同じような概念かもしれないが、Reactでは、ライブラリ実装として、use
というhooksが提供されているが、これとは別物。こちらもいずれ深掘りしたい。
Errorがthrowされたらどうするのか?
https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary
- コンポーネント内でErrorがthrowされた場合は、ErrorBoundaryという仕組みを使って例外を捉えてfallbackのコンポーネントを表示させる。
<ErrorBoundary fallback={"エラーが発生しました"}>
<Suspense fallback={<Loading />}>
<Component />
</Suspense>
</ErrorBoundary>
既存の非同期処理をSuspenseに置き換えるには
上記の通りで、既存のコードでuseEffect等でasync関数によりデータ取得を行っている場合、そのままSuspenseコンポーネントに置き換えることはできない。Promiseをthrowする仕組みを作る必要がある。
この仕組みを実装するのは割りとコストにみえるので、対応しているライブラリを使うのが一般的ななのかもしれない?
https://github.com/tanstack/query
https://swr.vercel.app/ja/docs/suspense
参考
https://qiita.com/uhyo/items/255760315ca61544fe33
throw Promiseの概念がしっくりこなかったのでとても参考になりました。
サンプルコードの大半もこちらを参考にさせていただいています。
今後深掘りしたいこと
- ErrorBoundary
- useDeferredValue
- startTransition
Discussion