React 18はReactの次期メジャーバージョンで、2021年の6月にalpha版が、11月にbeta版が出ました。また、Next.js 12でもReact 18のサポートが実験的機能として追加されました。React 18の足音がだんだんと我々に近づき、アーリーアダプターではない皆さんの視界にもいよいよReact 18が入ってきたところです。
特に、React 18ではServer-Side Rendering (SSR) のストリーミングサポートが追加されます。現在ReactでSSRを行いたい人の強い味方としてNext.jsが存在しているわけですが、Next.js 12でもReact 18を通してストリーミングの恩恵を受けることができます(Next.jsではSSR Streamingと呼んでいるようです)。また、厳密にはReact 18とは別ですが、React Server ComponentsについてもNext.js 12により実験的なサポートが提供されています。
SSRがストリーミングをサポートするということにより、従来のSSRの良い点(とくに1RTTでFirst Contentful Paintに到達できる点)を維持しつつ、スケルトン表示などのこれまで専らクライアントサイドで用いられていたパフォーマンス最適化技術(特にLargest Contentful Paintの改善のための技術)も取り入れることができます。この辺りの話はより速い WEB を目指す Next.jsがとても詳しいのでぜひご覧ください。
この記事では、いよいよ無視できなくなってきたReact 18に備えるにはどうすればいいのかについて端的にまとめます。
一言で言えば、答えはとにかくSuspenseを理解しろです。Suspenseのコンセプトさえを理解すれば、ストリーミングSSRやReact Server Componentsはその応用として理解することができ、これらの機能を使いこなせる状態に大きく近づきます。
Suspenseを理解する
以下では、5分でSuspenseを理解します。ただし、5分しかないので具体的なAPIを一つ一つ解説することはせず、コンセプトだけ解説します。具体的な使い方を知りたい方は公式ドキュメントや他の記事をご覧ください。
とはいえ、理解すべきことはたった一つです。Suspenseでは(より正確に言えばReact 18のConcurrent Renderingという機能では)、コンポーネントそのものが「ローディング中なのでまだレンダリングできない」という状態になることがあります。
最も典型的なローディングの例として、APIからデータを取ってくるコンポーネントを想定してみましょう。従来の典型的な方法では、次のようなコンポーネントを書くことになります(React Queryを使用する例)。
const TodoList: React.VFC = () => {
const { isLoading, error, data } = useQuery('todoData', loadTodoData);
if (isLoading) {
// ローディング中なのでスケルトンを表示
return <TodoListSkeleton />;
}
if (error) {
// エラー処理 (TODO)
return <p>gyaaaa</p>;
}
return <TodoListContents data={data} />;
}
このように、従来のやり方ではコンポーネント(TodoList)がデータの読み込みを担当し、ローディングかどうかという情報はそのコンポーネントが持つ状態でした。これにより、たとえローディング中であっても「TodoListをレンダリングできない」という事態は発生せず、ローディング中のUIを描画することはTodoListの責務でした。
それに対して、Suspenseを使う場合はTodoListの責務が簡略化されます。具体的には、ローディング中の処理(ついでにエラーの場合の処理)はTodoListの責務ではなくなります。
const TodoList: React.VFC = () => {
const { data } = useQuery('todoData', loadTodoData);
return <TodoListContents data={data} />;
};
では、この場合ローディング中はどうなるのでしょうか。答えは、TodoListがレンダリングできないと訴えてレンダリングを放棄します。
const App: React.VFC = () => {
return (<PageLayout>
<TodoList />
{/* TodoListさん「レンダリング無理!!!やめる!!!!!」
Appさん「!?」 */}
</PageLayout>);
};
より具体的には、ReactランタイムはTodoListをレンダリングしようとしますが、TodoListはデータがまだローディング中だからレンダリングできないと言ってレンダリングを中断します(これをコンポーネントがサスペンドすると呼びます)。具体的な機序としては、useQuery
が内部でPromiseをthrowすることで行います。
これにより、TodoListのレンダリングが無事に成功するのはすでに読み込みが完了した場合のみとなります。これがTodoListの責務を削減する秘訣です。
ローディングをハンドリングするSuspenseコンポーネント
このように、「コンポーネントがレンダリングを投げ出す」というのは新しい(Suspenseより前には無かった)概念です。上の例では、TodoListがレンダリングを投げ出してしまったらそれを使う側のコンポーネント(App)もレンダリングができないことになってしまいます。何せレンダリング結果が無いのですから。
「レンダリングを投げ出す」と表現しましたが、実際にはこのTodoListは裏で非同期通信を準備し、それが終わってローディング状態でなくなったら自分でリトライをかけてくれる真面目なコンポーネントです。一般に、サスペンドするコンポーネントはリトライがセットになっています。今回は5分で理解できる内容にするためにリトライ周りは省いています。
そのため、「内部のコンポーネントがレンダリングを投げ出して(サスペンドして)しまった場合に対処する」という役目のコンポーネントがReactから提供されます。それこそがSuspenseです。これはfallback propを指定することで、内部がサスペンドした場合の代替表示を指定できます。
const App: React.VFC = () => {
return (<PageLayout>
<Suspense fallback={<TodoListSkeleton />}>
<TodoList />
{/* TodoListさん「レンダリング無理!!!やめる!!!!!」
Suspenseさん「ええで」 */}
</Suspense>
</PageLayout>);
};
これにより、TodoListがサスペンドしてしまった場合でもSuspenseがそれに対処してくれるため、Appはレンダリングに成功します。内部が失敗しても外側への影響を抑えるというのは、try-catch文に近いものがありますね。
エラー処理については別途Error Boundaryを用意することで対処します。
このように、非同期ローディングを行うコンポーネントにおいて従来「ローディング中の処理」と「ローディング完了時の処理」はまとまっていましたが、Suspenseの機構を使うとこれを分離できます。従来はif (isLoading)
のような手続き的なプログラムだったところがSuspenseコンポーネントという宣言的な方法になったところも注目に値します。
Suspenseの面白いところは、複数コンポーネントをまとめて面倒見ることができるということです。次のようにすれば、ページの中の何かひとつでもサスペンドすればページ全体がスケルトンになります。ここでは3つのコンテンツがApp
の中にありますが、それをひとつのSuspenseで囲んでいます。
const App: React.VFC = () => {
return (<PageLayout>
<Suspense fallback={<PageSkeleton />}>
<MyProfile />
<TodoList />
<Comments />
</Suspense>
</PageLayout>);
};
一方で、それぞれを別々のSuspenseで囲めば、それぞれが独立してスケルトン表示を持ち、ローディング完了したところから表示されるようになります。
const App: React.VFC = () => {
return (<PageLayout>
<Suspense fallback={<MyProfileSkeleton />}>
<MyProfile />
</Suspense>
<Suspense fallback={<TodoSkeleton />}>
<TodoList />
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>
</PageLayout>);
};
このように、Suspenseの配置の仕方を変えることで、サスペンドの制御を細かに行うことができます。
SuspenseとReact 18の関係
残り時間が少ないのでReact 18の話に戻ります。
React 18のSSRストリーミングはSuspenseを前提にしています。SSRのストリーミングは「初期状態(ローディング中でスケルトンとかが表示されている状態)を表すHTMLを最速で送り、その後データが揃ったらスケルトンを置き換えるHTML断片を追加で送る」という方式です。
この処理単位はSuspense単位です。つまり、初期状態というのはSuspenseのfallback
が表示されている状態であり、その部分がローディング完了した場合はSuspenseの中身丸ごとを置き換えるHTML断片が送られてきます。
このように、Suspenseは「非同期的なレンダリングが行われるひとまとまりの領域」を定義するという意味があります(実際にはSuspenseをネストさせることもできるのでもう少し複雑ですが)。
要するに、ストリーミングSSRを活用するためには非同期処理をSuspenseを用いて書く必要がある上に、どのようにSSRのストリーミングが進むかを制御するにはSuspenseを適切な位置に置く必要があるということです。細かくSuspenseを置いて回れば、それだけSSRのストリーミングも細かく進むことになります。
また、React Server Componentsもその「レンダリングがサーバー側で(非同期に)行われる」という特徴から、(クライアント側から見ると)必然的にサスペンドの可能性があります。つまり、Server ComponentもSuspenseで囲む必要があるということです。
まとめ
この記事ではReactのSuspenseの概要を説明し、React 18およびReact Server Componentsの機能を使いこなすためにはSuspenseの理解が必要であることを説明しました。
5分と言わずもっとじっくりSuspenseを理解したいという方は、ぜひこちらにお進みください。