【Next.js】Hydrationエラーの正体を暴く:なぜ私のコードはサーバーとクライアントで衝突したのか

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

今回の記事は、Next.jsへの移行期に多くの開発者が直面する「Hydration Error」という非常に実用的なテーマを扱っています。元の文章の持つ「熱量」や「語り口」を活かしつつ、技術解説としての正確性を高め、誤字(「リョンポント」など)を修正し、より読み応えのある構成に整えました。

以下が、推敲・校正済みの【完成版の記事】です。


【Next.js】Hydrationエラーの正体を暴く:なぜ私のコードはサーバーとクライアントで衝突したのか

「お前、本当にReactわかってるのか?」

そう言いたげな顔をしたエンジニアの友人に詰め寄られる前に、このドキュメントを書き残しておきたい。

最近、Next.jsからApp Routerへの移行、そしてServer Components(RSC)の導入に乗り出した開発者が、必ずと言っていいほど突き当たる壁がある。それが「Hydration Error」だ。

私もその一人だった。数日前まで、私は自分の技術力に絶対の自信を持っていた。しかし、特定のコンポーネントをブラウザで表示した瞬間、真っ赤なエラーメッセージとともに、画面が白く染まる(あるいは一瞬だけ表示されてから崩れる)という地獄を経験した。

今回は、私がこの「Hydration」の迷宮で彷徨い、3時間以上を費やしてようやく突き止めた原因と、その解決策について徹底的に深掘りしていく。単なる「`useEffect`で囲めば直るよ」といった表面的な解説ではなく、なぜそれが起こるのかという内部構造まで語りたいと思う。

1. 前提知識:Hydrationとは何なのか?

まず、この問題の核心を理解するために「Hydration(ハイドレーション)」という概念を整理しておこう。

Next.jsのようなSSR(Server Side Rendering)やSSGを採用するフレームワークにおいて、サーバー側ではHTMLを生成する。しかし、そのHTMLだけではボタンをクリックしても反応しない、あるいは動的な状態変化を受け付けない「ただの文書」でしかない。

そこで、ブラウザに読み込まれた後、Reactがその静止したHTMLに対してJavaScriptを適用し、イベントリスナーを付与し、状態(State)を同期させるプロセスが必要になる。この「乾燥した(Dry)」状態のHTMLに、JavaScriptという「水(Hydration)」を注ぎ込んで動的な要素を注入する工程をHydrationと呼ぶ。

問題が発生するのは、「サーバーが生成した初期HTML」と「ブラウザでReactが初期レンダリングを試みた結果」が食い違ったときである。

2. 私が陥った「絶望のループ」

今回私がハマった具体的なケースは、以下のコンポーネントの実装時だった。

ユーザーのローカルストレージに保存されたテーマ(ダークモード/ライトモード)を判定し、それに合わせたクラス名を適用するボタンを作成したのだ。

// 失敗例:直感的に書いてしまったコード
import { useState } from 'react';

export default function ThemeToggle() {
  // ローカルストレージから直接値を取ろうとした
  const [theme, setTheme] = useState<string>(
    typeof window !== 'undefined' ? localStorage.getItem('theme') || 'light' : 'light'
  );

  return (
    <button className={theme === 'dark' ? 'bg-black text-white' : 'bg-white text-black'}>
      {theme === 'dark' ? '🌙 Dark Mode' : '☀️ Light Mode'}
    </button>
  );
}

このコード、一見すると「`typeof window !== 'undefined'` でチェックしているから安全だろう」と思えるかもしれない。しかし、Next.js(React)のレンダリングサイクルにおいては、これは非常に危険な挙動だ。

なぜこれがエラーになるのか?

1. サーバーサイドでのレンダリング:
SSR時、Node.js環境でこのコンポーネントが実行される。`typeof window`は`undefined`なので、`theme`はデフォルトの`'light'`として確定する。そして、サーバーは「ライトモード用のHTML」を生成してブラウザに送る。

2. クライアントサイドでのHydration:
ブラウザがHTMLを受け取った直後、Reactがハイドレーションを開始する。この時、Reactは「サーバーから届いたHTML(ライト)」と「自分のコンポーネントを実行した結果」を比較する。

3. 不一致の発生:
もしユーザーがすでにローカルストレージに`dark`を保存していた場合、ブラウザでの最初のレンダリング(ハイドレーション時)において、Reactは`localStorage`を見て`theme`を`dark`として処理しようとする。しかし、サーバーから届いたHTMLは「ライト」な状態。この「サーバーの意図」と「クライアントの計算」の乖離が検知された瞬間、Hydration Errorが爆発するのだ。

3. なぜ単なる条件分岐ではダメなのか(深掘り)

ここで重要なのは、「ブラウザで動くならいいじゃないか」という思考を捨てることだ。Reactはハイドレーションの際、DOM構造や属性の完全一致を求める。

たとえJavaScriptが実行される前にユーザーが見ることはなくても、Reactの内部エンジン(Reconciler)が「自分の計算した結果と今目の前にあるDOMが違うぞ?」と警告を出すこと自体が問題になる。 これが原因で、ハイドラーの挙動がおかしくなったり、スタイルが瞬時に書き換わる「Flicker(ちらつき)」が発生したりする。

この問題を解決するためのアプローチはいくつかあるが、それぞれの特性を理解しておく必要がある。

4. 解決策の比較と選定

状況に応じて使い分けるべき手法を以下の表にまとめた。

手法メリットデメリット推奨されるユースケース
useEffectによるマウント確認最も一般的で確実。ハイドレーション後の状態更新を保証する。最初のレンダリングと2回目のレンダリングの間に「ちらつき」が発生する可能性がある。ローカルストレージやCookieなど、ブラウザ固有の情報に基づく処理。
dynamic import (ssr: false)問題のあるコンポーネント自体をクライアント側でのみ読み込む。コンポーネント全体が遅延読み込みされるため、SEOや初期表示に影響が出る場合がある。複雑なサードパーティ製ライブラリを含むWidgetなど。
CSSによる制御JSに依存せず、サーバーとクライアントで共通のクラスを当てる。動的なロジック(ボタンの状態管理など)との組み合わせが難しい。テーマ切り替えや、メディアクエリに基づくスタイル変更。

5. 実装:正しい「テーマ判定」の実装例

私が最終的に採用した、最も堅実で再利用性の高いコードを以下に示す。ポイントは、「ハイドレーションが終わるまでは初期状態を維持し、マウント後にクライアント固有の値を反映する」という戦略をとることだ。

'use client';

import { useState, useEffect } from 'react';

/**
 * ハイドレーションエラーを防ぐためのカスタムフック
 * クライアントサイドでのみ実行されることを保証する
 */
function useHasMounted() {
  const [hasMounted, setHasMounted] = useState(false);
  useEffect(() => {
    setHasMounted(true);
  }, []);
  return hasMounted;
}

export default function ThemeToggle() {
  const hasMounted = useHasMounted();
  const [theme, setTheme] = useState<string>('light');

  // マウントされた後にのみ、ローカルストレージを参照する
  useEffect(() => {
    if (hasMounted) {
      const storedTheme = localStorage.getItem('theme');
      if (storedTheme) {
        setTheme(storedTheme);
      }
    }
  }, [hasMounted]);

  // ハイドレーションが終わるまでは、サーバーと同じ「light」を返す
  // これにより、サーバーとクライアントの初期レンダリングの一致を保証する
  if (!hasMounted) {
    return <button className="bg-gray-200 p-2 rounded">Loading...</button>; 
    // または、見た目を変えずに空の要素やデフォルトスタイルを返す
  }

  return (
    <button 
      className={theme === 'dark' ? 'bg-black text-white p-2 rounded' : 'bg-white text-black p-2 rounded'}
      onClick={() => {
        const newTheme = theme === 'light' ? 'dark' : 'light';
        setTheme(newTheme);
        localStorage.setItem('theme', newTheme);
      }}
    >
      {theme === 'dark' ? '🌙 Dark Mode' : '☀️ Light Mode'}
    </button>
  );
}

このコードの何が良いのか?

1. ハイドレーションの整合性: `hasMounted`がfalseの間は、サーバーとクライアントの双方で全く同じ(デフォルトの)状態をレンダリングする。これによりReactは「一致している」と判断し、エラーを出さない。
2. 予測可能な動作: マウントされた瞬間(useEffect内)に初めてローカルストレージを参照するため、ブラウザ固有のAPIに対する安全性が確保される。
3. 型安全性: TypeScriptの型定義を崩さず、クリーンな状態で実装できる。

6. さらなる深掘り:なぜ `dynamic` を使わないのか?

「でも、`next/dynamic` で `ssr: false` を使えばいいんじゃないの?」という疑問に対する答えは、「範囲(Scope)の問題」である。

`dynamic` はコンポーネントごとを切り離す強力な武器だが、それを使うとコンポーネントに関連するすべての状態管理やコンテキストも隔離される場合がある。今回のケースのように「ボタン一つだけの挙動」のために重いダイナミックインポートを使い、ハイドレーションのスキップを発生させるのは、パフォーマンスの観点からオーバーエンジニアリングになりがちである。

一方で、もしそのコンポーネントが、例えば「Google Mapsの埋め込み」や「複雑なWebAssemblyを動かすエディタ」のように、そもそもサーバーサイドで描画することが不可能な要素を含む場合は、迷わず `dynamic` を選ぶべきだ。

7. まとめ:技術的なハマりポイントを乗り越える思考法

今回のHydrationエラーとの戦いから学んだことは、単に「特定のコードの書き方」だけではない。

モダンなフレームワーク(特にNext.js App Router)を使う以上、私たちは「サーバーとクライアントは異なる環境で実行されている」という事実を常に意識しなければならないということだ。

1. 環境差異を意識する: `window`や`document`に依存するコードを書くときは、必ずそれがサーバー側で実行されないか、あるいは実行された際に不整合を起こさないかを考える。
2. 「予測される」ものだけを描画する: サーバーが描画した内容と、クライアントが最初に描こうとする内容は、100%一致していなければならない。動的な変化は、すべて「マウント後(Hydration後)」のフェーズに回すのが鉄則である。
3. ログを読み解く: Hydration Errorが出たとき、ただ「直るまでいじる」のではなく、「なぜReactが怒っているのか?」という内部構造を考える。

次10件のエラーメッセージに出会ったとき、今回の経験があなたのデバッグ時間を数時間短縮してくれることを願う。技術の深淵は深いが、そこを一つずつ紐解いていく過程こそが、エンジニアとしての醍醐味なのだから。