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

profile image

Suspenseの登場の背景と実際の使い方、そしてどのような状況で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>fallback propsにロード時に表示するコンポーネントを入れればいいのです。

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

Image.png

初期HTMLは次のようになります。

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

その後、サーバーからコメントコンポーネントのためのデータが準備されると、以下のような処理を行います。

html
<div hidden id="comments">
  <!-- Comments -->
  <p>First comment</p>
  <p>Second comment</p>
</div>
<script>
  // This implementation is slightly simplified
  document.getElementById('sections-spinner').replaceChildren(
    document.getElementById('comments')
  );
</script>

Image.png

このようなプロセスにより、画面に何かを表示するためにすべてのデータを取得する必要がなくなりました。

ハイドレーションの改善

初期HTMLを生成したものの、ハイドレーションが開始されるためには、JavaScriptを全て読み込む必要があります。このような場合、lazy を通じた code splittingでハイドレーション過程も分離することができます。

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

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

Image.png

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

Image.png

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

AnimatedImage.gif

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

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

  1. use Hookを使ったデータフェッチング

    javascript
    import { use } from 'react';
    
    // 데이터를 가져오는 함수
    function fetchUserData(userId) {
      return fetch(`/api/users/${userId}`)
        .then(res => res.json());
    }
    
    function UserProfile({ userId }) {
      const user = use(fetchUserData(userId));
      return (
        <div>
          <h1>{user.name}</h1>
          <p>{user.bio}</p>
        </div>
      );
    }
    
    // Suspense로 래핑
    function App() {
      return (
        <Suspense fallback={<Loading />}>
          <UserProfile userId={1} />
        </Suspense>
      );
    }
  2. Lazyコンポーネント

    javascript
    import { lazy, Suspense } from 'react';
    
    // 동적으로 로드되는 컴포넌트
    const HeavyComponent = lazy(() => import('./HeavyComponent'));
    
    function App() {
      return (
        <div>
          <h1>My App</h1>
          <Suspense fallback={<Loading />}>
            <HeavyComponent />
          </Suspense>
        </div>
      );
    }
  3. サーバーコンポーネントのasync

    javascript
    // 서버 컴포넌트
    async function BlogPost({ id }) {
      const post = await fetchBlogPost(id);
      return (
        <article>
          <h1>{post.title}</h1>
          <p>{post.content}</p>
        </article>
      );
    }
    
    function BlogPage({ id }) {
      return (
        <Suspense fallback={<LoadingPost />}>
          <BlogPost id={id} />
        </Suspense>
      );
    }
  4. useDeferredValue 使用

    javascript
    function SearchResults({ query }) {
      // 검색어 변경을 지연시켜 처리
      const deferredQuery = useDeferredValue(query);
    
      return (
        <Suspense fallback={<Loading />}>
          <SearchResultsList query={deferredQuery} />
        </Suspense>
      );
    }

Suspenseがトリガーされない場合

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

  1. useEffect 内部でのデータペッチング

    javascript
    // ❌ Suspense를 발동시키지 않음
    function UserProfile({ userId }) {
      const [user, setUser] = useState(null);
    
      useEffect(() => {
        fetchUserData(userId).then(setUser);
      }, [userId]);
    
      if (!user) return <Loading />;
      return <h1>{user.name}</h1>;
    }
  2. 一般的なPromise処理

    javascript
    // ❌ 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 を使う必要があります。


参考

❤️ 0
🔥 0
😎 0
⭐️ 0
🆒 0