Suspenseを活用するために手に入れたいメンタルモデルを考える
React の Suspense 、活用していますか?
Suspense はデータのローディング状況を管理するのに便利なコンポーネントで、v18で正式リリースされています。この Suspense ですが、なかなか活用が難しいのです。
というのも、従来のデータ取得プロセスをベースにした考え方と大きく異なるため、これまでのメンタルモデルでは理解が困難で、十分に活用するためには多くの学習コストを支払う必要があります。
わたしたち Gaji-Labo は Next.js を活用したプロダクトチームの支援を得意としています。Next.js の進化は目まぐるしいものがありますが、そのなかでも Suspense は重要な位置をしめていると考えます。
この記事では、Suspenseがどのようなものかを知り、十分に活用するために必要な新しいメンタルモデルについて考えていきます。
Suspense はどう働くか
まず、Suspenseのことを知りましょう。どのように動く機能でなにをしてくれるのか、公式ドキュメントから情報を集めます。
<Suspense>
を使うことで、子要素が読み込みを完了するまでフォールバックを表示させることができます。
function Albums({ artistId }) {
const albums = use(fetchData(`/${artistId}/albums`));
return (
<ul>
{albums.map(album => (
<li key={album.id}>
{album.title} ({album.year})
</li>
))}
</ul>
);
}
function ArtistPage({ artist }) {
return (
<>
<h1>{artist.name}</h1>
<Suspense fallback={<Loading />}>
<Albums artistId={artist.id} />
</Suspense>
</>
);
}
処理の流れをさらうと、どうやらこのような事をしています。
ArtistPage
がアーティストの情報を受け取りartist.id
を使ってアルバムの情報を非同期通信で取得- アルバムの情報がロードされるまでの間、
<Loading />
を表示 - アルバムの情報のロードが完了したら、
<Albums />
でアルバムリストを描画
コードの中に見慣れないものが2つあります。 use
と Suspense
です。これらが Suspense の機能を動かす核となります。これらのAPIが何をしてくれるのかを掘り下げていきましょう。
Suspense は何をしてくれるか
まずは Suspense
の動作を調べてみました。単純化すると、次のような事をしてくれるようです。
- 子コンポーネントから
throw
されたPromise
をキャッチする - その
Promise
が解決されるまでの間、fallback
を表示する Promise
が解決されたらchildren
を描画
頭の中でイメージしやすいようにコードで表現してみましょう。
// このコードはイメージです(正しく動作しません)
function FakeSuspense({
children,
fallback,
}: { children: ReactNode; fallback: ReactNode }) {
try {
return children;
} catch (error) {
// Promiseがthrowされた場合
if (error instanceof Promise) {
// Promiseが解決されるまでfallbackを表示
return fallback;
}
// その他のエラーは上位に伝播
throw error;
}
}
このコードは機能をイメージしやすくするためのもので、正しく動かないのでうっかり試さないようにご注意ください。(動かない理由はここでは割愛します)
ここでおさえておきたいのは、Suspense を機能させるためには children
から Promise
を throw
しなければならないということです。 従来 Promise
は .then()
や await
などで値を受け取るのが通例でした。それを throw
させているのが Suspense の大きな特徴ですね。
use は何をしてくれるか
続いて use
の動作を調べてみます。(なんという抽象度の高い命名なのでしょうか!)Suspense
と連携する時の use
は次のような流れで処理をおこなうようです。
- 引数に
Promise
を受け取る Promise
が解決されるまでの間、それをthrow
するPromise
が解決されると、解決された値を返却する
こちらもイメージしやすいようにコードで表現してみます。
// このコードはイメージです(正しく動作しません)
const promiseCache = new WeakMap();
function fakeUse<T>(promise: Promise<T>): T {
// Promise はキャッシュされている必要がある
if (!promiseCache.has(promise)) {
promiseCache.set(promise, { status: "pending" });
promise.then((result) => {
promiseCache.set(promise, { status: "fulfilled", result });
});
}
// キャッシュから状態を取得
const cache = promiseCache.get(promise);
if (cache.status === "pending") {
throw promise; // Suspenseにキャッチさせるためにthrowする
}
return cache.result; // 解決済みの結果を返す
}
もちろんこのコードも正しく動きませんので、実際に使わないでくださいね。使うと無限ループに陥ったりします。
さて、use
と Suspense
の関係性がなんとなくつかめたでしょうか。 use
から投げられた Promise
を Suspense
がキャッチすることでこの機能は実現しています。データ取得の責務は children
に負わせ、 Suspense
はキャッチした Promise
からロード状況を把握してローディング状態を管理しているのです。
従来の開発方法と比較する
Suspense を大枠理解したところで、従来の開発方法とどう違うのかを考えてみます。比較のため、上の例と同じ処理をしてみます。このコードはイメージです。
// このコードはイメージです
export function ArtistPage({ artist }) {
const [albums, setAlbums] = useState([]);
useEffect(() => {
(async () => {
const data = await fetchData(`/${artist.id}/albums}`);
setAlbums(data);
})();
}, []);
if (!albums.length) {
return <div>Loading...</div>;
}
return (
<>
<h1>{artist.name}</h1>
<Albums albums={albums} />
</>
);
}
馴染みのある見た目のコードですね。説明の必要はないかもしれませんが、以下のような処理をしています。
- データを取得する
- データのロードが完了したらステートを更新する
- ステートが更新されるまでの間、
Loading...
を表示する - ステートが更新されたらコンテンツを描画する
得られる結果としては、Suspenseとそう変わりません。
Suspense実装との比較
では、このトラディショナルなコードと Suspense による実装ではどのような違いがあるのでしょうか。
最も分かりやすい違いは、Suspense が投げられた Promise
を catch
するのに対して、トラディショナルなコードでは Promise
で解決されるデータを await
で受け取っている点ですね。
そしてもうひとつ大切なのは、責務の所在の違いです。従来の方法ではデータの取得もローディングの管理も責務を負っているのは親コンポーネントです。対して Suspense では、データの取得は子コンポーネントが、ローディングの管理は Suspense
コンポーネントが責務を負っていて、親コンポーネントはそれらを配置するだけです。関心の分離がなされています。
Suspense のメンタルモデルを考える
さて本題です。上で述べたようなトラディショナルな実装とSuspenseの差分から、Suspense のメンタルモデルを考えていきます。この差分がつまり、考え方をアップデートしなければならない部分だと考えます。
- データ管理の責務をどこが負うか
- データはどのように渡ってくるか
ひとつずつ理解を進めてみましょう。
1. データ管理の責務をどこが負うか
Suspense では、データ管理の責務はそのデータを使いたいコンポーネントが負います。
従来の方法では、データ取得もローディングの管理も、親コンポーネントが一元的におこなっていました。Suspense ではそのデータを必要とするコンポーネントから直接データを取得し、ローディング状態はそれをラップする Suspense
コンポーネントが管理します。
つまり、データを管理するスコープが異なります。
トラディショナルな実装ではページ全体、あるいはアプリ全体がスコープとなり、言ってみれば中央集権的な管理となります。対して Suspense 実装では、 Suspense
コンポーネントをブロックとしてスコープが区切られます。データ管理はそのデータを必要とするコンポーネントでおこなう形で実装されます。
親コンポーネントはデータ管理に関心をもつ必要がなくなり、設計はよりシンプルになっていきます。
2. データはどのように渡ってくるか
Suspense では、データは下から浮かび上がってきます。
従来は、親コンポーネントで取得したデータを、それを必要とする子孫コンポーネントに渡すためにバケツリレーをしたり、状態管理ライブラリを活用したりしていました。つまり、上からデータが降りてくる、トップダウンであると言えます。
対して Suspense では、下層から throw
された Promise
を try ... catch
でキャッチするわけで、データは下からやってきます。イメージとしては JavaScript のイベントバブリングの機構とも似ていますね。データはバブリングしてきます。
データは親コンポーネントを通過してきません。ですので、親コンポーネントはデータの事を知りません。知る必要がないということです。
おわりに
この記事では React の Suspense について、
- Suspense がどう動くか
- それはトラディショナルなデータ管理とどう違うのか
- その違いから、Suspense のメンタルモデルをどう考えるか
という観点で考えました。
トラディショナルな方法は React誕生以前から続いてきた長い実績のあるもので、そこから新しい方法へシフトするためにはメンタルモデルを新たに構築する必要があります。それはとても労力を要することです。
この記事が、Suspense の動きを頭でイメージするための一助になれば幸いに思います。
Gaji-Labo フロントエンドエンジニア向けご案内資料
Gaji-Labo は Next.js, React, TypeScript 開発の実績と知見があります
フロントエンド開発の専門家である私たちが御社の開発チームに入ることで、バックエンドも含めた全体の開発効率が上がります。
「既存のサイトを Next.js に移行したい」
「人手が足りず信頼できるエンジニアを探している」
「自分たちで手を付けてみたがいまいち上手くいかない」
フロントエンド開発に関わるお困りごとがあれば、まずは一度お気軽に Gaji-Labo にご相談ください。
オンラインでのヒアリングとフルリモートでのプロセス支援にも対応しています。
Next.js, React, TypeScript の相談をする!