React Suspenseの登場の背景と使い方を紹介します。

🌐

この記事は DeepL によって翻訳されました。誤訳があれば教えてください!

SSR(Server Side Rendering)の制限事項

サーバーサイドレンダリング(SSR)は初期読み込み性能とSEOの面で大きなメリットを提供しますが、いくつかの重要な限界点があります。この記事では伝統的なSSRの問題点をみて、React 18でこれを解決するために導入されたSuspenseについて説明します。

1.データフェッチングのブロック問題

SSRの一番大きな問題点の1つは、サーバーから全てのデータを一度に取得する必要があることです。コンポーネントツリーをHTMLでレンダリングする前に、そのページに必要なすべてのデータが用意されている必要があります。

例えば、次のようなコンポーネントで構成されているブログポストページを考えてみましょう。

  • ナビゲーションバー
  • サイドバー
  • 投稿内容
  • コメントセクション

もし、コメントデータを取得するのに時間がかかる場合、開発者は次のような選択をする必要があります。

  1. コメントをサーバーのレンダリングから除外する

    • メリット: 他のコンポーネントを素早く表示することができます。
    • デメリット: ユーザーはJavaScriptがロードされるまでコメントを見ることができません。
  2. コメントを含めてサーバーでレンダリングする

    • 長所: コメントが初期HTMLに含まれる
    • 短所: コメントデータを待つためにページ全体のレンダリングが遅れる。

2.ハイドレーションの問題

SSRの2つ目の大きな問題はハイドレーション(hydration)プロセスと関係しています。Reactはサーバーで生成されたHTMLとクライアントのコンポーネントツリーをマッチングするため、すべてのコンポーネントのJavaScriptコードがロードされるまで待つ必要があります。

また、Reactのハイドレーションプロセスに問題があります。現在Reactはコンポーネントツリー全体を一度にハイドレーションするため、次のような問題が発生します。

  1. ツリー全体がハイドレーションされるまで、どのコンポーネントともインタラクションできない。

  2. 特に低スペックのデバイスでパフォーマンスの問題が発生する

    • 重いJavaScriptロジックがある場合、画面がフリーズする。
    • ユーザーが他のページに移動したい場合でも、ハイドレーションの完了を待たなければならない。

3.ウォーターフォール(Waterfall)プロセスの限界

これらの問題の根本原因は、SSRが次のような逐次的な"ウォーターフォール"プロセスに従うからです。

  1. データフェッチング (サーバー)
  2. HTMLレンダリング (サーバー)
  3. JavaScriptコードの読み込み(クライアント)
  4. ハイドレーション(クライアント)

各ステップは、前のステップが完全に完了して初めて開始できるため、全体的なパフォーマンスとユーザーエクスペリエンスが低下します。

新しい解決策Suspense

これらの問題を解決するために、React 18ではSuspenseが導入されました。Suspenseを使用すると、アプリのさまざまな部分を個別にロードしてハイドレーションすることができ、上記の問題を効果的に解決することができます。

使い方

使い方は簡単です。データを全部読み込むのを待ちたくないコンポーネントを<Suspense>で包んで、fallbackpropsに読み込み時に表示するコンポーネントを入れるだけです。

function BlogPost() {
  return (
    <div> </div
      <Nav /> </div
      <Sidebar /> </div
      <Article /> </div
      <Suspense fallback={<CommentsSkeleton />}> <Suspense fallback={<CommentsSkeleton />}> </div
        <Comments /> </div
      </Suspense></div> </div
  )です;
}

Image.png

初期HTMLは次のように描かれます。

<main> <!
  <!-- 省略...--> <!
  <section id="comments-spinner"> <!
    <!-- Spinner --> <!
    <img width=400 src="spinner.gif" alt="Loading..." /> <img width=400 src="spinner.gif" alt="Loading..." /> <!
  </section> </section
</main>

その後、サーバーでコメントコンポーネントのためのデータが準備されたら、下記のような処理をします。

<div hidden id="comments"> <!
  <!-- Comments -->
  <p>First comment</p>
  <p>Second comment</p>
</div> </div
<script>
  // この実装は少し簡略化したものです
  document.getElementById('sections-spinner').replaceChildren(
    document.getElementById('comments')
  )を使用しています;
</script> </script

Image.png

このような過程で、画面に何かを表示するため全てのデータを取得する必要がなくなりました。

ハイドレーション改善

初期HTMLを生成しても、ハイドレーションはまだJavaScriptが全て読み込まないと開始できない場合があります。このような場合、lazyを使って code splittingでハイドレーション過程も分離することができます。

const Comments = lazy(() => import('./Comments'));

function BlogPost() { return ()
  return (
    <div> </div
      <Nav /> の
      <Article /> </div
      <Suspense fallback={<CommentsSkeleton />}> <Suspense fallback={<CommentsSkeleton />}> </div
        <Comments /> <Suspense
      </Suspense>。
    </div> </div
  )です;
}

Image.png

コメントセクションを除いた残りのJavaScriptがロードされた部分からハイドレーションが行われます。後、コメントコンポーネントのJavaScriptまで全部ロードされたら、コメントもハイドレーションが行われます。

Image.png

ここまでの過程をまとめると次のようになります。

AnimatedImage.gif

Suspenseを発動させるための条件

すべての種類のデータ読み込みがSuspenseをトリガーするわけではありません。 では、どのようなデータソースがSuspenseと互換性があるのでしょうか?

  1. useHookを使ったデータフェッチ
import { use } from 'react';

// データを取得する関数
function fetchUserData(userId) { return fetch(`/api/api/userId) { return fetch(`/api/userId')
  return fetch(`/api/users/${userId}`)
    .then(res => res.json());
}

function UserProfile({ userId }) { {
  const user = use(fetchUserData(userId));
  return (
    <div> </div
      <h1>{user.name}</h1>。
      <p>{user.bio}</p></p
    </div> </div
  )です;
}

// Suspenseでラッピング
function App() {
  return (
    <Suspense fallback={<<Loading />}> <Suspense fallback={<Loading />}> </font></font
      <UserProfile userId={1} /> <Suspense fallback={<Loading />}> </Suspense
    </Suspense
  )です;
}
  1. Lazyコンポーネント
import { lazy, Suspense } from 'react'// 動的にロードされるコンポーネント
const HeavyComponent = lazy(() => import('./HeavyComponent'));

function App() { return
  return (
    <div> </div
      <h1>私のアプリ</h1></h1>。
      <Suspense fallback={<Loading />}> </h2> </h3> </h4
        <HeavyComponent /> </div
      </Suspense>。
    </div> </div
  )です;
}
  1. サーバーコンポーネントのasync
// サーバーコンポーネント
async function BlogPost({ id }) {
  const post = await fetchBlogPost(id);
  return (
    <article>の場合
      <h1>{post.title}</h1>。
      <p>{post.content}</p
    </article> </article
  )です;
}

function BlogPage({ id }) {
  return (
    <Suspense fallback={<LoadingPost />}> <Suspense fallback={<LoadingPost id={id} />> </id
      <BlogPost id={id} /> <BlogPost id={id} />.
    </Suspense>。
  )です;
}
  1. useDeferredValueを使う
function SearchResults({ query }) { // 検索キーワードの変更を遅延して処理する
  // 検索語の変更を遅延して処理します。
  const deferredQuery = useDeferredValue(query);

  return (
    <Suspense fallback={<Loading />}> <Suspense fallback={<Loading />}> <SearchResultsList
      <SearchResultsList query={deferredQuery} /> </SearchResultsList query={deferredQuery} /> </suspense
    </Suspense>。
  )です;
}

Suspenseが発生しない場合

一方、次のような一般的なデータペッチングパターンはSuspenseをトリガーしません。

  1. useEffect内部でのデータペッチング
// ❌ Suspenseをトリガーしません。
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => { {
    fetchUserData(userId).then(setUser);
  }, [userId]);

  if (!user) return <Loading />return <h1>{user.name}</h1>;
}
  1. 一般的なPromise処理
// ❌ Suspenseをトリガーしない
function UserProfile({ userId }) { {
  const [user, setUser] = useState(null);
  const [error, setError] = useState(null);

  Promise.resolve(fetchUserData(userId))
    .then(setUser)
    .catch(setError);

  if (error) return <Error error={error} />if (!user) return <Loading />return <h1>{user.name}</h1>;
}

Tanstack Queryを使う場合

今まで使ってたuseQueryではなく useSuspenseQueryを使う必要があります。

参考文献