React Suspense + lazy で隣接するコンポーネントがあるときの Hydration Mismatch 問題と回避法

React 18 以降では、Suspenselazy を活用することでコード分割と非同期ロードを簡潔に実現できます。しかし、SSR (サーバーサイドレンダリング) と組み合わせたときに、Hydration Mismatch という厄介な問題に直面することがあります。特に「lazy コンポーネントの隣に通常コンポーネントが並んでいる」ケースでは、サーバーとクライアントで生成される HTML が微妙に異なり、Hydration の段階でエラーや警告が発生します。

本記事では、実際の再現例と原因を分解し、実践的な回避策を提示します。

1. エラーメッセージの例

実際に Hydration Mismatch が発生すると、以下のような警告がコンソールに出ます。

Warning: Text content did not match. Server: "Loading..." Client: "Hello Component"

あるいは次のようなメッセージが表示されることもあります。

Warning: Expected server HTML to contain a matching <div> in <div>.

これはサーバーがレンダリングした Suspense のフォールバックと、クライアントが最終的に描画する lazy コンポーネントの内容が異なるために起こります。

2. 再現コード

以下のようなシンプルな構成を考えてみます。

// components/LazyHello.tsx
export default function LazyHello() {
  return <div>Hello Component</div>;
}
// pages/index.tsx
import { Suspense, lazy } from "react";

const LazyHello = lazy(() => import("../components/LazyHello"));

export default function Home() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <LazyHello />
      </Suspense>
      <div>Regular Component</div>
    </div>
  );
}

この構成を SSR すると、サーバー側は Loading... を返します。一方でクライアントは直ちに LazyHello の内容を取得して Hydration を試みます。その結果、隣接するコンポーネントが存在することで DOM 構造が期待通りに一致せず、Hydration Mismatch が発生 することがあります。

3. なぜ隣接コンポーネントが影響するのか

Hydration はサーバーから返却された HTML をクライアントが再利用しつつ、React の仮想 DOM と同期させる仕組みです。Suspense のフォールバックはサーバー HTML に埋め込まれますが、クライアントではすぐに lazy コンポーネントがロードされて差し替えられることがあります。

その際、隣接する通常コンポーネントとの間に意図せぬ DOM 差分が発生しやすくなります。たとえば以下の流れが問題です。

  1. SSR: <div>Loading...</div><div>Regular Component</div>
  2. CSR: <div>Hello Component</div><div>Regular Component</div>

サーバーが返したフォールバックの <div> はクライアントで <div>Hello Component</div> に置き換えられますが、この切り替えが同期される前に React が DOM を検証すると mismatch が発生してしまうのです。

4. 解決策 1: Suspense でまとめてラップする

最もシンプルな回避策は、lazy コンポーネントとその兄弟要素を一つの Suspense 境界でラップすることです。これによりサーバーとクライアントでレンダリングされる DOM 構造の差分が最小化されます。

export default function Home() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <div>
        <LazyHello />
        <div>Regular Component</div>
      </div>
    </Suspense>
  );
}

これでフォールバックから最終的な描画までの流れが一貫し、Mismatch を回避できます。

5. 解決策 2: クライアント専用レンダリングに切り分ける

SSR と CSR の差異を避けるために、lazy コンポーネントをクライアントサイド限定でレンダリングするという方法もあります。Next.js では next/dynamic を利用して簡単に実装可能です。

import dynamic from "next/dynamic";

const LazyHello = dynamic(() => import("../components/LazyHello"), {
  ssr: false,
});

export default function Home() {
  return (
    <div>
      <LazyHello />
      <div>Regular Component</div>
    </div>
  );
}

この方法ではサーバーには LazyHello が出力されず、クライアントでのみ描画されるため Hydration Mismatch は発生しません。ただし初期表示時には空白になるため UX に注意が必要です。

6. 解決策 3: フォールバックの設計を工夫する

フォールバック要素をサーバーとクライアントで同一の DOM に近づけることで mismatch を避けられる場合もあります。例えば div を使う場合、構造やクラス名を lazy コンポーネントに合わせておくことで React の差分検知がスムーズになります。

<Suspense fallback={<div className="placeholder">Loading...</div>}>
  <LazyHello />
</Suspense>

また、suppressHydrationWarning を利用して一時的に警告を抑制することもできますが、根本的な解決にはならないため最終手段と考えるべきです。

まとめ

React の Suspense + lazy を利用したときに隣接する兄弟コンポーネントがあると Hydration Mismatch が発生するのは、サーバーとクライアントで異なる DOM 構造が生成されるため です。これを回避するための代表的なアプローチは次の通りです。

  • Suspense 境界でまとめてラップする
  • クライアント限定レンダリングに切り分ける
  • フォールバックの DOM 構造を工夫する

特に SSR を伴う Next.js プロジェクトでは、Suspense の設計次第でパフォーマンスと安定性に大きな差が生まれます。Hydration の仕組みを理解し、DOM の一貫性を維持することがエラー回避のカギとなります。

参考

タイトルとURLをコピーしました