【絶報】Next.jsのHydrationエラーで3時間溶かした話と、その後の地獄からの生還記

技術ブログおよびガジェットレビューの専門編集者として、ご提示いただいたドラフトを査読・推敲いたしました。

今回の記事は、Next.js初心者が必ずと言っていいほど突き当たる「Hydration Error」という壁を、実体験に基づいたエモーショナルな語り口と、深い技術的背景の解説を織り交ぜて非常に分かりやすく構成されています。

以下に、読者の共感を呼びつつ、技術的な満足度も高い【完成版の記事】を出力します。


【絶報】Next.jsのHydrationエラーで3時間溶かした話と、その後の地獄からの生還記

「なぜ動かないのか。コードは間違っていないはずなのに……」

深夜2時、モニターの前に座り込み、真っ赤なエラーログを眺めながら頭を抱える。そんな経験をしたことがあるだろうか。特にNext.js(App Router)への移行期において、この「Hydration Error」という魔物は、多くの開発者を絶望の淵へと突き落としてきた。

今回は、私が実際にプロジェクトで直面し、数時間の格闘の末に解決した「クライアントサイド固有のデータとSSRの不整合」に関する技術的ハマりポイントを徹底的に深掘りする。単なる「こう書けばいいよ」という表面的な解説ではなく、なぜそのエラーが起きるのか、Reactの内部構造はどう動いているのか、そして実務で使える代替案との比較まで、余すことなく書き切る。

1. 事件の始まり:消えた通知バッジと謎の警告

事の発端は、あるプロジェクトのマイページ実装だった。
ユーザーがログインしているかどうか、あるいは未読の通知があるかどうかを判定して、ナビゲーションバーに小さな赤いドット(通知バッジ)を表示させる。一見、非常に単純な機能だ。

しかし、Next.jsのApp Routerを採用した瞬間、地獄の門が開いた。

// 最初に書いてしまった「素人な」実装例
export default function Navbar() {
  const [hasNotification, setHasNotification] = useState(false);

  // ローカルストレージから未読数を取得(※実際はAPI等から取る想定)
  useEffect(() => {
    const count = localStorage.getItem('unread_count');
    if (count) setHasNotification(parseInt(count) > 0);
  }, []);

  return (
    <nav>
      <span>Notifications</span>
      {hasNotification && <span className="badge">!</span>}
    </nav>
  );
}

このコードを書いた直後、開発環境では動いているように見えた。しかし、ビルドしてデプロイした瞬間、あるいは特定のブラウザでリロードした際に、以下のような警告がコンソールに猛烈な勢いで叩きつけられた。

`Text content does not match server-rendered content. The server rendered "Notification" but the client rendered "Notification!"`

これが、悪名高きHydration Error(ハイドレーションエラー)の正体である。

2. なぜ「Hydration」でコケるのか:内部構造の深掘り

このエラーを単に「useEffectの中で処理すればいいじゃん」と片付けてしまうのは簡単だが、なぜReactがこれほどまでに怒っているのか、その根本的な理由を知っておく必要がある。

Hydrationとは何か?

Next.jsなどのSSR(Server Side Rendering)を採用するフレームワークにおいて、Hydrationとは「サーバーから送られてきた静的なHTMLに、クライアント側でJavaScriptを実行してイベントリスナーを付与し、Reactのステートと同期させるプロセス」を指す。

1. Step 1 (Server): Reactがサーバー上でコンポーネントをレンダリングし、HTMLを出力する。
2. Step 2 (Browser): ブラウザがそのHTMLを受け取り、表示する(この時点ではまだボタンなどは反応しない)。
3. Step 3 (Hydration): クライアント側でReactが起動し、サーバーから届いたDOM構造と、クライアント側で生成した仮想DOMを照らし合わせる。

ここで問題が発生する。
「サーバーが作ったHTML」と「クライアントが最初の描画で試みた内容」が1ピクセルでも、あるいはスペース1つでも異なれば、Reactは不整合(Mismatch)と判断してパニックを起こすのだ。

なぜ私のコードはダメだったのか?

上記のサンプルコードでは、`hasNotification` の初期値が `false` である。しかし、重要なのは「Hydrationの瞬間」に実行される処理は、サーバーサイドでのレンダリング結果と完全に一致していなければならないというルールだ。

今回のケースでは、`localStorage` はブラウザ固有の機能であり、サーバー側(Node.js環境)には存在しない。そのため、サーバー側でレンダリングする際には「バッジなし」の状態になるが、クライアント側での最初の描画プロセスにおいて何らかの理由で状態の不一致が生じると、このエラーが発生する。

3. 解決策の比較と選択基準

この問題を解決するためのアプローチはいくつか存在する。それぞれにメリット・デメリットがあり、状況に応じて使い分けるのがプロの技術選定である。

手法仕組みメリットデメリット推奨されるケース
useEffectによるマウント検知`isMounted` フラグを立ててからクライアント専用処理を行う実装がシンプルで一般的。最初のレンダリングと2回目の描画の間に「ちらつき」が発生する可能性がある。ローカルストレージやCookieに基づく動的なUI変更。
dynamic import (ssr: false)クライアント側でのみ読み込むコンポーネントとして分離する確実にサーバーサイドの影響を排除できる。コード分割が発生し、特定の部品だけ読み込みの遅延が発生する。ブラウザ専用API(Geolocation, WebGL等)に依存するコンポーネント。
Cookieによる事前判定Cookieを介してサーバー側で状態を確定させるHydrationエラーを回避しつつ、初期表示で正しい状態を表示できる。状態の更新を即座に反映させるには、API連携や複雑なロジックが必要になる。ログイン状態、テーマ設定など、リクエスト時に確定する情報の処理。

4. 実践的な解決策:コードによる解説

では、実際にどう書くべきなのか。私がプロジェクトで採用した「正攻法」を2つ紹介する。

パターンA:マウント検知によるガード(最も汎用的)

これは、Hydrationエラーの根本原因である「サーバーとクライアントの初期状態の乖離」を物理的に切り離す手法だ。

"use client";

import { useState, useEffect } from 'react';

/**
 * クライアントサイドでのみマウントされたかどうかを判定するカスタムフック
 * これにより、Hydrationエラーを防ぎつつクライアント固有の処理を行う
 */
export function useHasMounted() {
  const [hasMounted, setHasMounted] = useState(false);
  useEffect(() => {
    setHasMounted(true);
  }, []);
  return hasMounted;
}

export default function NotificationBadge() {
  const hasMounted = useHasMounted();
  const [count, setCount] = useState(0);

  useEffect(() => {
    // マウント後にのみ実行されるため、サーバーサイドでの不整合が発生しない
    const savedCount = localStorage.getItem('unread_count');
    if (savedCount) {
      setCount(parseInt(savedCount));
    }
  }, []);

  // mountされるまでは「何もしない(またはプレースホルダー)」を返すことで
  // サーバーとクライアントの最初のレンダリングを一致させる。
  if (!hasMounted) {
    return <span className="placeholder" />; // または null
  }

  return (
    <div className="badge-container">
      {count > 0 && <span className="dot">{count}</span>}
    </div>
  );
}

パターンB:Next.jsの動的インポートによる隔離

特定のコンポーネントが複雑なブラウザ操作を伴う場合、そのコンポーネント自体を「クライアント専用」として切り出す。

import dynamic from 'next/dynamic';

// SSRを無効化して動的にインポートする
// これにより、サーバー側ではこのコンポーネントはレンダリングされず、
// クライアント側でのみJavaScriptが読み込まれた後に実行される。
const DynamicNotification = dynamic(
  () => import('../components/Notification_Client'), 
  { 
    ssr: false,
    loading: () => <div className="skeleton" /> // ロード中のプレースホルダー
  }
);

export default function Navbar() {
  return (
    <nav>
      <div className="logo">My App</div>
      <DynamicNotification />
    </nav>
  );
}

5. 結局、どれを使うべきか?

このあたりを読んでいると、「結局どっちがいいの?」と思うかもしれない。結論から言えば、「データの性質」で決めるのが最もスマートだ。

1. ユーザー設定(ダークモード、言語選択など)の場合:
可能であればCookieを使用し、Middlewareやサーバーコンポーネント側で判定を行うべきである。これにより、Hydrationエラーを回避するだけでなく、リクエストの最初から正しいUIを表示できるため、UXが劇的に向上する。

2. ブラウザ固有のAPI(LocalStorage, IndexedDB, センサーなど)の場合:
パターンA(`useHasMounted`)またはパターンB(`dynamic`)を選択する。特に、単純なフラジ管理であればパターンAの方がコードの粒度が細かく制御できるため推奨される。

6. まとめ:失敗から学んだこと

Hydrationエラーに遭遇したとき、最初の一歩は「とりあえず `useEffect` の中に書く」ことで解決することが多い。しかし、それはあくまで対症療法であり、なぜそれが動くのかを理解していないと、将来的に複雑な状態管理(Context APIやZustandなど)と組み合わせた際に、再び同じ壁にぶつかることになる。

Next.jsのApp Routerは非常に強力だが、それゆえに「サーバーとクライアントの境界」に対する高い意識が求められる。

今回の経験から学んだことは以下の3点だ。

  • 「Hydration」を意識せよ: 最初のレンダリングで一致しないものは絶対に含めてはいけない。

  • ブラウザ特有の挙動は隔離せよ: `window` や `localStorage` を使う瞬間、そこには明確な境界線があることを意識する。

  • ドキュメントを読み込む: 公式ドキュメントにある「Hydration」に関する警告は、単なるお節介ではなく、設計上の重要なヒントである。


技術の深淵に潜ることは苦しいが、その壁を乗り越えた先には、より堅牢で洗練されたコードへの道が開けている。もし今、君が赤文字のHydrationエラーに絶望しているなら、この記事が少しでも救いになることを願っている。