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
<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