SSR(Server Side Rendering)の制限事項
サーバーサイドレンダリング(SSR)は初期読み込み性能とSEOの面で大きなメリットを提供しますが、いくつかの重要な限界点があります。この記事では伝統的なSSRの問題点をみて、React 18でこれを解決するために導入されたSuspenseについて説明します。
1.データフェッチングのブロック問題
SSRの一番大きな問題点の1つは、サーバーから全てのデータを一度に取得する必要があることです。コンポーネントツリーをHTMLでレンダリングする前に、そのページに必要なすべてのデータが用意されている必要があります。
例えば、次のようなコンポーネントで構成されているブログポストページを考えてみましょう。
- ナビゲーションバー
- サイドバー
- 投稿内容
- コメントセクション
もし、コメントデータを取得するのに時間がかかる場合、開発者は次のような選択をする必要があります。
-
コメントをサーバーのレンダリングから除外する
- メリット: 他のコンポーネントを素早く表示することができます。
- デメリット: ユーザーはJavaScriptがロードされるまでコメントを見ることができません。
-
コメントを含めてサーバーでレンダリングする
- 長所: コメントが初期HTMLに含まれる
- 短所: コメントデータを待つためにページ全体のレンダリングが遅れる。
2.ハイドレーションの問題
SSRの2つ目の大きな問題はハイドレーション(hydration)プロセスと関係しています。Reactはサーバーで生成されたHTMLとクライアントのコンポーネントツリーをマッチングするため、すべてのコンポーネントのJavaScriptコードがロードされるまで待つ必要があります。
また、Reactのハイドレーションプロセスに問題があります。現在Reactはコンポーネントツリー全体を一度にハイドレーションするため、次のような問題が発生します。
-
ツリー全体がハイドレーションされるまで、どのコンポーネントともインタラクションできない。
-
特に低スペックのデバイスでパフォーマンスの問題が発生する
- 重いJavaScriptロジックがある場合、画面がフリーズする。
- ユーザーが他のページに移動したい場合でも、ハイドレーションの完了を待たなければならない。
3.ウォーターフォール(Waterfall)プロセスの限界
これらの問題の根本原因は、SSRが次のような逐次的な"ウォーターフォール"プロセスに従うからです。
- データフェッチング (サーバー)
- HTMLレンダリング (サーバー)
- JavaScriptコードの読み込み(クライアント)
- ハイドレーション(クライアント)
各ステップは、前のステップが完全に完了して初めて開始できるため、全体的なパフォーマンスとユーザーエクスペリエンスが低下します。
新しい解決策Suspense
これらの問題を解決するために、React 18ではSuspenseが導入されました。Suspenseを使用すると、アプリのさまざまな部分を個別にロードしてハイドレーションすることができ、上記の問題を効果的に解決することができます。
使い方
使い方は簡単です。データを全部読み込むのを待ちたくないコンポーネントを<Suspense>で
包んで、fallback
propsに読み込み時に表示するコンポーネントを入れるだけです。
function BlogPost() {
return (
<div> </div
<Nav /> </div
<Sidebar /> </div
<Article /> </div
<Suspense fallback={<CommentsSkeleton />}> <Suspense fallback={<CommentsSkeleton />}> </div
<Comments /> </div
</Suspense>。
</div> </div
)です;
}
初期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
このような過程で、画面に何かを表示するため全てのデータを取得する必要がなくなりました。
ハイドレーション改善
初期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
)です;
}
コメントセクションを除いた残りのJavaScriptがロードされた部分からハイドレーションが行われます。後、コメントコンポーネントのJavaScriptまで全部ロードされたら、コメントもハイドレーションが行われます。
ここまでの過程をまとめると次のようになります。
Suspenseを発動させるための条件
すべての種類のデータ読み込みがSuspenseをトリガーするわけではありません。 では、どのようなデータソースがSuspenseと互換性があるのでしょうか?
use
Hookを使ったデータフェッチ
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
)です;
}
- 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
)です;
}
- サーバーコンポーネントの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>。
)です;
}
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をトリガーしません。
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>;
}
- 一般的な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を
使う必要があります。
参考文献