👌

今後の React ではどの範囲を Suspense で囲むかという設計が重要になってくる

2023/02/04に公開

はじめに

今後のReactではどの範囲をSuspenseで囲むか
という設計が重要になってくる、という話をSuspenseの説明とともにしていきます!

React18がリリースされて1年近く経つので今更感あるかもしれませんが、お付き合いください。

Suspense とは

React18で正式な機能として実装された機能
React16.6で実験的機能として追加されていた

Suspense でできること

  1. データ取得中ローディング状態の宣言的な記述
  2. コンポーネント単位でのSSR
  3. コンポーネント単位でのJSロード
  4. コンポーネント単位でのHydration

Suspense は単にローディングを宣言的に記述できるだけの機能ではない

Suspenseはローディングを宣言的に記述できるもの、といった内容を目にすることが多い印象です。
しかし、Suspenseは単にローディングを宣言的に記述できるだけの機能ではありません。

こちらの discussion では、Suspenseについて
https://github.com/reactwg/react-18/discussions/37

アプリケーションのユーザーはコンテンツをより早く表示し、はるかに迅速に操作を開始できるようになります。アプリの最も遅い部分が、高速な部分を引きずることはありません。

と言及しています。

※上記記事にSuspenseについてかなり詳しく書いてありますのでぜひ一読を!

1. データ取得中ローディング状態の宣言的な記述

今まで(Suspenseを利用しない)

export const Comments: FC = () => {
  const {comments, loading} = useData();
  
  if (loading) return <div>ローディング中</div>
  
  return <div>{comments.map(comment => (<div>{comment}</div>))}</div>
} 

Suspenseを使うと

const Comments: FC = () => {
  const {comments} = useData();
  return <div>{comments.map(comment => (<div>{comment}</div>))}</div>
}

const Index: FC = () => {
  return (
    <Suspense fallback={<div>ローディング中</div>}>
      <Comments />
    </Suspense>
  )
}

fallbackにローディング時に表示するコンテンツを渡します。
宣言的に記述することができ、Commentsコンポーネントはコメントのリストを表示するという責務だけを持つことができます。

補足

Suspenseコンポーネントは子コンポーネント内で
Promisethrowされるとサスペンド状態(ローディング状態)をキャッチ。
サスペンド状態のキャッチでfallbackコンテンツ表示する。

おまけ:SWRを利用して Suspense を使う

SuspenseSWRなどのデータフェッチ系ライブラリと併用して利用していくのが一般的になるかと思います。
SWRを利用してSuspenseの機能を使うためにはオプションsuspense: trueを設定します。

const Comments: FC = () => {
  const { data: comments } = useSWR(key, fetcher, {
    suspense: true
  });
  return <div>{comments.map(comment => (<div>{comment}</div>))}</div>
}

const Index: FC = () => {
  return (
    <Suspense fallback={<div>ローディング中</div>}>
      <Comments />
    </Suspense>
  )
}

TanStack Query (旧React Query)などもSuspenseに対応しております。

前提:SSR と Hydration について

前提理解として、SSRHydrationについて軽く触れておきます。

SSR とは

サーバー側でデータフェッチ、HTMLCSSの構築などの各処理を実行してからクライアントに描画データを返却すること

SSRの流れ

  1. クライアントからWebサーバーにリクエスト
  2. Webサーバーでリクエストを受け取りAPIサーバーに必要なデータをリクエスト
  3. APIサーバーからデータを取得
  4. WebサーバーAPIサーバーから取得したデータをもとにHTMLを構築
  5. WebサーバーHTMLをクライアント(ブラウザ)に返却
  6. HTML(静的な状態)を描画
  7. JavaScriptをロードする
  8. ロードしたJavaScriptHTMLHydrateする
  9. Hydrateが完了し、Interactiveな状態になる

※Hydration, Hydrate とは
サーバー側で生成されたHTMLJSの各ロジックを接続していくこと。
つまり静的な状態のHTML要素にJSのイベントハンドラなど接続していき、動的ウェブページに変換していく処理のこと。
クリックなどのイベントが発火されない静的な状態のUIをクリック可能な動的なUIにする処理。


SSR のメリット

  1. クライアントのマシンやブラウザのスペックに影響しない
  2. リクエスト毎に常にページが最新の状態になる
  3. SEOに強い

などなど

SSR のデメリット

  1. ページ全体のデータフェッチを待たないとページを表示できない
  2. ページ全体のJavaScriptロードを待たないとHydrationを開始できない
  3. ページ全体のHydrationを待たないとIntaractiveな状態にならない

などがあると思います。

1. ページ全体のデータフェッチを待たないとページを表示できない

HTMLをブラウザに返す前に重いデータ、軽いデータ関係なく全てのデータをフェッチしなければならず、それによりページ初期表示が遅くなります。

2. ページ全体の JavaScript ロードを待たないと Hydration を開始できない

ページ全体のJavaScriptのサイズが大きい場合、Hydration開始まで時間がかかってしまいます。

3. ページ全体の Hydration を待たないと Intaractive な状態にならない

Hydrationが完了しているコンポーネントが存在しても、Hydrationが遅いコンポーネントの処理が終了するまで他のコンポーネントもInteractiveな状態にはなりません。
そのため、クリックなどの操作が可能になるまでに他のコンポーネントの処理を待つ必要があります。

これらのデメリットをSuspenseで解消できることをこれから説明していきます。
ポイントとしては
Suspenseで囲ったコンポーネントと、その他の部分とで各処理が可能になることにあります。

2. コンポーネント単位での SSR

今まで(Suspenseを利用しない)

ページ単位でSSRが実行される
ページ内全てのデータ取得やHTML構築処理の完了を待ってからHTML要素が返却される。

デメリット
初期表示が遅くなる。
最もデータフェッチ、HTML構築が遅い要素(コンポーネント)の処理の完了を待たなければならないため。

こういったコンポーネント配置のページを例に考えてみます。

function App() {
  return (
    <div className="App">
      <Sidebar />
      <LightData />
      <HeavyData />
    </div>
  );
}
  • Sidebar:データフェッチが不要なコンポーネント
  • LightData:軽いデータのフェッチが必要なコンポーネント
  • HeavyData:重いデータのフェッチが必要なコンポーネント

ページ単位でデータ取得やHTML構築処理を待つ必要があるため、
データ取得が不要なコンポーネント、取得データが軽いコンポーネントも、他のデータが重いコンポーネントが各処理を終えるまで表示されません。

Suspense を使うと

コンポーネント単位(Suspense で囲った単位)でSSRが実行される
コンポーネント単位でデータ取得、HTML構築の完了を待ってから、コンポーネント単位でHTML要素を返却。
(これがStreaming HTMLというもの)

これにより1.ページ全体のデータフェッチを待たないとページを表示できないというデメリットが解消されます。
データフェッチ、HTML構築が遅い要素(コンポーネント)の処理の完了を待たずページを表示できるため、初期表示が早くなる。

データフェッチ、HTML構築が遅い要素(コンポーネント)は処理が完了する前にfallbackコンテンツが表示される。

コンポーネントをSuspenseで囲います。

function App() {
  return (
    <div className="App">
      <Suspense fallback={<div>ローディング...</div>}>
        <Sidebar />
      </Suspense>
      <Suspense fallback={<div>ローディング...</div>}>
        <LightData />
      </Suspense>
      <Suspense fallback={<div>ローディング...</div>}>
        <HeavyData />
      </Suspense>
    </div>
  );
}

※ 青がローディング終了後、表示されている状態 / 白がローディング状態

①最初にデータ取得の必要がないSidebarが表示されます

②次に軽いデータ取得が必要なLightDataが表示されます

③最後に重いデータ取得が必要なHeavyDataが表示されます

3. コンポーネント単位での JS ロード

今まで(Suspenseを利用しない)

ページ全体のJavaScriptロードを待たないとHydrationを開始できない。
JavaScriptのサイズが大きいコンポーネントがある場合、サイズの小さいコンポーネントもHydration開始まで時間がかかってしまう。

Suspense を使うと

コンポーネント単位(Suspense で囲った単位)で JavaScript ロードが実行される
ページ全体のJavaScriptロードを待つ必要がなく、コンポーネント単位でHydrationを開始することができます。

これにより2. ページ全体のJavaScriptロードを待たないと Hydrationを開始できないというデメリットが解消されます。

4. コンポーネント単位での Hydration

今まで(Suspenseを利用しない)

ページ単位でHydration処理が完了する。
ページにある全てのコンポーネントのHydration処理を待たないと、どのコンポーネントもIntaractiveな状態になりません。

Suspense を使うと

コンポーネント単位(Suspenseで囲った単位)でHydration処理が行なわれます。

これにより、3. ページ全体のHydrationを待たないと Intaractiveな状態にならないというデメリットが解消されます。

Suspense を用いいることでHydration処理がコンポーネント単位でできるようになった

// React.lazy()と組み合わせて利用する必要があるようです
import { lazy } from 'react';
const Sidebar = lazy(() => import('./Sidebar.js'));
const LightData = lazy(() => import('./LightData.js'));
const HeavyData = lazy(() => import('./HeavyData.js'));

function App() {
  return (
    <div className="App">
      <Suspense fallback={<div>ローディング...</div>}>
        <Sidebar />
      </Suspense>
      <Suspense fallback={<div>ローディング...</div>}>
        <LightData />
      </Suspense>
      <Suspense fallback={<div>ローディング...</div>}>
        <HeavyData />
      </Suspense>
    </div>
  );
}

仮に3つのコンポーネントでHTMLが静的な状態で表示されていて、それらのコンポーネントはHydration処理はまだ行われていない状態とします。
その場合、Reactはコンポーネントツリーの早い段階で見つかった(
つまり上から順番に見つかった)Suspenseで囲ったコンポーネントからHydration処理を開始します。
今回の場合はSidebarコンポーネントから開始されます。

※ 緑が動的な状態 / 黄がHydration中状態 / 白がHydrationを開始していないかつ静的な状態

① SidebarでHydration開始

② LightDataでHydration開始

③ HeavyDataでHydration開始

④ 全てのコンポーネントがIntaractiveな状態に

更にユーザーの関心対象を検知して優先的にHydrationしてくれる機能がある

デフォルトでは先述したように、コンポーネントツリーの早い段階で見つかったもの(配置されている要素の上)から順にHydration処理が行われます。

しかし、ユーザーの関心対象のコンポーネントを検知してHydrationを行ってくれる機能があります。

これがSelective Hydrationという機能。
このSelective Hydrationという機能がすごい。

どんな動きをするかというと

① SidebarでHydration開始

② ユーザーがHeavyDataをクリックする

③ SidebarのHydrationを中断し、HeavyDataでHydration開始
Reactがユーザーの関心対象を検知して、HeavyDataコンポーネントを優先的にHydrationしようとする。

④ HeavyDataが先にIntaractiveな状態に

こんな動作をしてくれるようです。

さいごに

最初にも申し上げたとおり
今後のReactではどの範囲をSuspenseで囲むか
という設計が特にパフォーマンスの観点から重要になってくると思います。

ただ、まだ実際のプロダクトでSuspenseを導入できている訳ではないので今後実際の開発で試していくなかで知見をためていきたいです。
(どこまでの粒度、単位でSuspenseを利用していくべきなのかなど)

最後までお読みいただきありがとうございました!

参考資料

ありがとうございました!

Discussion