プロの編集者として、ご提示いただいたドラフトを査読・推敲いたしました。
今回のリライトでは、以下のポイントに重点を置いて修正を行っています。
1. 誤字の修正: 「クライler」といった入力ミスを正確な技術用語(Chrome等)へ修正。
2. 構成の最適化: 読者がよりスムーズに理解できるよう、見出しや強調箇所の調整を行い、技術ブログとしての「読みやすさ」と「権威性」を両立させました。
3. 表現のブラッシュアップ: 中堅エンジニアに向けた記事として、語り口を維持しつつ、解説の論理構成をよりシャープに整えました。
以下が完成版の記事です。
【完全攻略】Next.js App Router移行で「地雷」を踏まないためのHydrationエラーとコンポーネント設計の深掘り
最近、Next.jsのApp Routerへの移行や、新規プロジェクトでの導入が加速しています。しかし、現場で開発を進める中で、「Server Components(RSC)とClient Componentsの境界線」の判断に頭を悩ませていないでしょうか?
特に、「特定の環境だけで発生する謎のHydrationエラー」や、「どこまでをクライアント側で処理すべきかという設計判断」は、中堅エンジニアであれば一度は必ずと言っていいほど突き当たる壁です。
本記事では、単に「動かないから`use client`を付ける」といった表面的な解決策ではなく、なぜその問題が発生するのかという内部構造の理解から、実務で使えるベストプラクティスまでを徹底的に深掘りします。
1. なぜ「Hydration Error」は発生するのか?
まず前提として、Next.js(およびReact)におけるHydration(ハイドレーション)とは、サーバーで生成されたHTMLと、クライアント側で実行されるJavaScriptによる初期レンダリング結果を一致させるプロセスのことを指します。
このプロセスにおいてエラーが発生する最大の原因は、「サーバーが生成したDOM構造・属性」と「ブラウザが最初に描画しようとした内容」の不一致にあります。
実務で頻出する「地雷」の3大パターン
特に以下の3つは、現場で非常に高い頻度で発生するケースです。
1. タイムゾーンや日付の直接描写
サーバー(Node.js環境)ではUTCとして処理されるが、ブラウザ(Chromeなど)ではローカルタイムとして解釈されるため、`<time>`タグ内の文字列が変わってしまうケース。
2. WindowオブジェクトやLocalStorageへの依存
`window.innerWidth` や `localStorage.getItem()` をコンポーネントのレンダリング中に直接参照すると、サーバーサイドには存在しない値(またはundefined)を参照しようとするため、不一致が発生します。
3. ブラウザ固有の拡張機能による介入
これはデバッグが非常に難解ですが、翻訳ツールやパスワードマネージャーなどの拡張機能がDOMを操作した際に、Hydrationエラーとして検知されることがあります。
これらに対する安易な解決策として `useEffect` 内で状態を更新する手法がありますが、それでは「一瞬だけサーバーと異なる内容が表示される(Flash of Unstyled Contentに近い挙動)」というUXの劣化を招く可能性があります。
2. Server Components vs Client Components の判断基準
ここで多くのエンジニアが迷うのが、「このコンポーネントはどちらで書くべきか?」という設計判断です。App Routerでは、安易に「動かないから`use client`」と付与するのではなく、以下の特性を理解した上で戦略を立てる必要があります。
| 特徴 | Server Components (RSC) | Client Components |
|---|---|---|
| 実行場所 | サーバーのみ(ビルド時またはリクエスト時) | ブラウザ(およびサーバーでのプリレンダリング) |
| JS Bundleサイズ | 0(クライアントに送信されない) | コンポーネント分のJSが含まれる |
| データフェッチ | 直接fetchを記述可能。バックエンドへの直接アクセス可。 | Fetch APIを使用。基本はAPIルートを経由。 |
| インタラクティビティ | 不可(クリック、状態変化など) | 可能(useState, useEffect, onClick等) |
| 主な用途 | データの取得、レイアウトの構築、静的コンテンツ | ボタン操作、フォーム入力、ブラウザAPI利用 |
設計の鉄則:「最小単位でのClient Component化」
重要なのは、「クライアント側の機能が必要な部分だけを切り出すこと」です。
例えば、巨大なフォーム全体を`use client`にするのではなく、送信ボタンや個別のインプットバリデーションを行う小さなコンポーネントだけを独立させ、それをServer Componentから呼び出す構造にします。この設計により、バンドルサイズを抑えつつ、インタラクティブな体験を維持することが可能になります。
3. 実践的な解決策:Hydrationエラーを回避する「No-SSR」パターン
では、どうしてもブラウザ固有のAPI(`window`など)を使わなければならないが、SEOや初期表示のためにサーバーサイドでもレンダリングを行わなければならないという矛盾をどう解決すべきか。
推奨されるのは、「クライアントでのみ実行されるコンポーネント」をカプセル化する手法です。
単に `use client` を付けるのではなく、以下のようなラッパーコンポーネントを介することで、Hydrationエラーを安全に回避できます。
"use client";
import { useState, useEffect } from 'react';
/**
* ブラウザ固有のAPIや、Hydrationエラーを引き起こす可能性のある
* ロジックを隔離するためのラッパーコンポーネント
*/
export function SafeClientComponent({ children }: { children: React.ReactNode }) {
const [isMounted, setIsMounted] = useState(false);
// マウント時に状態を更新。これにより、サーバーとクライアントでの初期レンダリングの不一致を防ぐ。
useEffect(() => {
setIsMounted(true);
}, []);
// マウントされるまでは、サーバーと同じ内容(またはnull)を返す
if (!isMounted) {
return <div style={{ visibility: 'hidden' }}>{children}</div>;
}
return <>{children}</>;
}
/**
* 実際の利用例:
* ブラウザの幅やLocalStorageに依存する処理をこの中で行う。
*/
export default function ResponsiveWidget() {
// このコンポーネント自体はクライアントコンポーネントだが、
// SafeClientComponentでラップすることでHydrationエラーを防ぐ。
return (
<SafeClientComponent>
<div className="p-4 border rounded">
{/* ここに window.innerWidth などに依存するロジックを記述 */}
<p>現在のウィンドウ幅に基づいた処理を実行中</p>
</div>
</SafeClientComponent>
);
}
この手法の肝は、「サーバー側でのレンダリング結果」と「クライアントでの最初のレンダリング結果」を意図的に一致させることにあります。`isMounted`がfalseの間は、サーバーと同じ内容(あるいは空のプレースホルダー)を表示するため、Hydrationエラーが発生しなくなります。
4. データフェッチの最適化:Waterfallを防ぐための設計
Next.js App Routerにおいてもう一つの大きな落とし穴が、「データ取得のウォーターフォール(滝)」です。
従来のページモデルでは、クライアント側で複数のAPIを順番に叩くことが多々ありましたが、Server Componentsの導入により「サーバーサイドでの並列フェッチ」が可能になりました。しかし、これを知らずに`await`を連発すると、レンダリングが逐次処理の待ち状態になり、TBT(Total Blocking Time)が悪化します。
悪い例(ウォーターフォールが発生)
// Server Component
async function Page() {
// ここでユーザー情報を待ってから商品情報を取ろうとする(直列処理)
const user = await fetchUser();
// 次に商品の在庫状況を待つ
const products = await fetchProducts();
return (
<div>
<UserDetail user={user} />
<ProductList products={products} />
</div>
);
}
良い例(並行処理とStreamingの活用)
Promiseを配列に入れて`Promise.all`で待つか、あるいは`Suspense`を適切に配置することで、データの準備ができるものから順次表示する設計が求められます。import { Suspense } from 'react';
// 独立したデータフェッチを行うコンポーネント
async function ProductList() {
const products = await fetchProducts(); // ここで待機しても、他の部分のレンダリングを妨げない
return <div>{/* 商品一覧の描画 */}</div>;
}
async function Page() {
// 構造を見やすくするため、個別のコンポーネントに分離。
return (
<main>
<h1 className="text-xl font-bold">ショップページ</h1>
{/* 商品リストの読み込み中にSkeletonを表示する */}
<Suspense fallback={<p>Loading products...</p>}>
<ProductList />
</Suspense>
{/* ユーザー情報の読み込みも独立させる */}
<Suspense fallback={<p>Loading profile...</p>}>
<UserProfile />
</Suspense>
</main>
);
}
このように、「何が原因でレンダリングがブロックされているか」を明確にする設計が重要です。特に、`fetch`の結果を待つためにページ全体が白く止まることを防ぐため、粒度の細かいSuspenseの配置は実務上、ほぼ必須に近い技術選定となります。
5. まとめ:モダンなスタックにおける「思考の転換」
Next.js App RouterやReact Server Componentsに移行する際、私たちが向き合うべきは単なるツールの習熟ではありません。「どこで実行されるか(Runtime)」と「いつまで待たせるか(Latency)」を切り離して考えることです。
1. Hydrationエラーへの対処:
安易な`use client`の多用ではなく、サーバーとクライアントの状態の同期を意識したコンポーネント設計を行う。特にブラウザ固有APIを使用する箇所は適切なラッパーでカプセル化する。
2. データフェッチの戦略:
ウォーターフォールを回避し、可能な限りServer Components側でデータを解決する。その上でUXを最大化するために、「即座に表示できるもの」と「時間がかかるもの」を`Suspense`で切り分ける判断を行う。
3. 保守性のためのドキュメント化:
チーム開発において「なぜこのコンポーネントをクライアント用にするのか」という意図を残すことは、技術的負債を防ぐために非常に重要です。
最新の技術スタックは、適切な設計を行えば強力な武器になります。しかし、基礎となるレンダリングの仕組みやネットワークの挙動を無視すると、デバッグに時間を溶かす「沼」にハマることになります。
自分のコードがなぜ動かないのか、あるいはなぜ遅いのか。その問いに対する答えは常に「レンダリングのタイミングと場所(Where & When)」の設計にあることを覚えておきましょう。