【完成版】Next.jsのHydration Errorに絶望したあの日から学んだ、確実な「Client-side Only」の実装術

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

今回の記事は、Next.js開発において誰もが一度は突き当たる「Hydration Error」という非常に実用的なテーマです。読者が抱く「なぜこれが起きるのか?」という根本的な疑問に寄り添いつつ、プロフェッショナルとして推奨する解決策を提示する構成に整えました。

特に、技術解説としての正確性を保ちつつ、ブログ記事としての読み物としての面白さ(共感と解決への導線)を強化しています。


【完成版】Next.jsのHydration Errorに絶望したあの日から学んだ、確実な「Client-side Only」の実装術

最近、Next.js(App Router)を用いたモダンなフロントエンド開発に深く潜り込んでいると、避けては通れない「壁」にぶち当たることがあります。それが「Hydration Error(ハイドレーションエラー)」です。

「SSR(Server Side Rendering)を使っているんだから、サーバーで生成したHTMLをクライアントが受け取って表示するだけじゃないか?」と、最初は楽観的に考えていた自分を叱りたくなります。実際には、このエラーの裏側にはReactのレンダリングサイクル、ブラウザ固有のAPI、そして「実行タイミング」という非常にデリケートな問題が複雑に絡み合っているからです。

今回は、私が実務や個人開発で幾度となくハマり、最終的に「これが正解だ」と辿り着いた解決策を、技術的な背景とともに深掘りしていきます。


なぜHydration Errorは起きるのか?

まず前提として、Next.jsやNuxtといったSSRフレームワークが裏側で何を行っているかを整理しましょう。

サーバー側でHTMLを生成し、それをブラウザに送信します。その後、ブラウザ側でJavaScriptが実行され、React(またはVue)がDOMを「水和(Hydrate)」させます。この時、「サーバーで作ったHTML」と「クライアントで最初にレンダリングした結果」が1ビットでも違えば、Reactは警告を出す(あるいはエラーを投げて描画を崩す)のです。

これが起こる主な要因は、大きく分けて以下の3つです。

1. 時間や乱数などの動的な値

`new Date()` や `Math.random()` をコンポーネントのレンダリング中に直接使用するケースです。サーバーでの実行時刻と、ユーザーのブラウザに届いた瞬間の時間は必ず異なります。これが原因で「サーバーでは『12:00:00』だったのに、クライアントでは『12:00:01』になった」という微細な差が生じ、Hydration Errorへと繋がります。

2. ブラウザ固有のオブジェクトへのアクセス

`window`、`document`、`localStorage`、`navigator` など。これらはサーバー環境(Node.js)には存在しません。しかし、単純に `if (typeof window !== 'undefined')` を使って条件分岐をしても、Reactのレンダリングサイクルによっては「サーバーとクライアントでノードの構造が変わった」と判定されることがあります。

3. 不規則なDOM構造

例えば、`<p><div>...</div></p>` のように、HTML仕様として許容されないネストを行った場合です。ブラウザはこれらを自動的に修正しようとするため、Reactが想定するDOM構造とズレが生じ、結果としてHydration Errorを引き起こすことがあります。

よくある「あ、これダメなやつ」のパターン

現場でよく見かけるのが、以下のような実装です。

// ⚠️ これは危険な例
const MyComponent = () => {
  // サーバーとクライアントで時間がズレるためHydration Errorの原因になる
  const time = new Date().toLocaleTimeString(); 
  
  return <div>現在の時刻は {time} です</div>;
};

これを見た初心者は「`useEffect`の中でセットすればいいんじゃね?」と考えるでしょう。その通りです。しかし、単に `useEffect` を使うだけでは不十分なケースがあります。なぜなら、最初のレンダリング(サーバー)で一度描画され、次のレンダリング(クライアントの初期化後)で書き換わるため、一瞬だけ「古いデータ」や「空の状態」が表示される「Flicker(ちらつき)」が発生するからです。


解決策の比較と使い分け

Hydration Errorを回避するためのアプローチはいくつかあります。それぞれにメリット・デメリットがあるため、状況に応じて使い分けるのがプロの仕事です。

手法メリットデメリット推奨されるケース
useEffect による状態管理実装が非常に簡単。標準的なReactの作法に従う。初期表示と2回目の描画で内容が変わるため、一瞬「ちらつき」が発生する。ロード中を考慮しても良い、あるいは単純なフラグ管理で済む場合。
dynamic import (ssr: false)ブラウザでのみ実行されることを保証できる。コード分割の恩恵も受けられる。コンポーネント単位で切り離す必要があるため、設計の変更が必要になる場合がある。`window` や `localStorage` を多用する複雑なUI要素(地図、エディタなど)。
カスタムHook (hasMounted)コードを共通化でき、再利用性が高い。ロジックをクリーンに保てる。単体では「ちらつき」の防止にはならず、あくまで実装上のガード句として機能する。汎用的なコンポーネントライブラリを作る際や、小規模な差異を許容する場合。

技術的に正しい実装例

それでは、実際に現場で使えるコードを見ていきましょう。ここでは「ユーザーのローカルストレージから取得した設定を反映する」という、非常にありがちなケースを例に挙げます。

1. `useEffect` と `useState` を組み合わせた王語パターン

これが最も一般的ですが、あえて「ちらつき」を許容するかどうかで判断します。

'use client';

import { useState, useEffect }  from 'react';

export default function LocalStorageTheme() {
  const [isMounted, setIsMounted] = useState(false);

  // マウントされた後にのみ、クライアント側の処理を実行する
  useEffect(() => {
    setIsMounted(true);
  }, []);

  // サーバーサイドでは isMounted は false なので、
  // クライアントと一致する「プレースホルダー」を表示する。
  if (!isMounted) {
    return <div className="placeholder">Loading...</div>; // または空の要素
  }

  const theme = localStorage.getItem('theme') || 'light';
  return <div className={theme}>テーマ適用済み</div>;
}

2. `dynamic` を使った「完全隔離」パターン

Next.js特有の機能を利用して、サーバーサイドでのレンダリング自体をスキップさせる手法です。これが最も確実な解決策の一つです。

// components/DynamicComponent.tsx
import dynamic from 'next/dynamic';

// SSRを無効化して動的にインポートする
const ClientOnlyComponent = dynamic(
  () => import('@/components/ClientSideFeature'),
  { ssr: false }
);

export default function Page() {
  return (
    <div>
      <h1>メインコンテンツ</h1>
      {/* このコンポーネントはサーバー側では何もレンダリングされない */}
      <ClientOnlyComponent />
    </div>
  );
}

3. 高度なテクニック:カスタムフックによるカプセル化

何度も同じような「クライアント専用の処理」を書くのは手間がかかります。そんな時は、以下のようなカスタムフックを作成するのがスマートです。

'use client';

import { useState, useEffect } from 'react';

/**
 * クライアントサイドでのみ有効なコンポーネントをラップするためのHook
 * Hydration Errorを防ぎつつ、コードの可読性を高く保つ。
 */
export function useHasMounted() {
  const [hasMounted, setHasMounted] = useState(false);
  useEffect(() => {
    setHasMounted(true);
  }, []);
  return hasMounted;
}

// 利用例
export default function SafeComponent() {
  const hasMounted = useHasMounted();

  if (!hasMounted) return null; // またはスケルトンを表示

  // ここからはwindowやlocalStorageに安全にアクセス可能
  const data = window.location.search; 
  return <div>Query: {data}</div>;
}


深掘り:なぜ `dynamic` を使うと「重い」と言われるのか?

ここまでは「動くかどうか」の話ですが、プロとして「パフォーマンスへの影響」も考慮する必要があります。

`dynamic(() => ..., { ssr: false })` を多用すると、そのコンポーネントはクライアントサイドでのみロードされるため、LCP(Largest Contentful Paint)などの指標に悪影響を与える可能性があります。
例えば、メインのヒーロー画像やタイトル部分を `ssr: false` で囲ってしまうと、ユーザーには真っ白な画面が見える時間が長くなってしまうからです。

そのため、基本的には 「サーバー側でレンダリングできればするだけやる」のが鉄則です。 Hydration Errorが出る箇所を見極め、本当にクライアント側でしか動かないロジック(ブラウザのAPIに依存するもの)だけに `dynamic` や `useHasMounted` を適用するのが、最適化された設計と言えるでしょう。


まとめ:結局どれを使えばいい?

今回の結論をまとめると、以下の判断基準で選ぶのがベストです。

1. 「サーバーでもレンダリングしたいが、一部のデータだけクライアントで確定させたい」場合
→ `useEffect` と状態管理(`isMounted`など)を使う。ただし、初回のちらつきを許容できるか検討すること。
2. 「ブラウザ固有のAPI(WebRTC, Canvas, LocalStorage等)に依存するコンポーネント」の場合
→ `dynamic(..., { ssr: false })` を使う。これが最も安全で、型定義やクリーンな分離も行いやすい。
3. 「共通ライブラリやUIキットを作っている」場合
→ ユーザーに選択肢を与えるか、`useHasMounted` のような便利なフックを同梱する。

Hydration Errorは単なるエラーメッセージではありません。それは「サーバーとクライアントの境界線」を意識せよというフレームワークからの警告です。この仕様を正しく理解し、適切な手法を選択することで、より堅牢でUXの良いプロダクトを作れるようになるはずです。

次にあなたが Hydration エラーに遭遇したとき、この「なぜ起きているのか?」という背景を思い出して、最適な武器を選んでみてください。


💡 おまけ:関連する技術スタックの比較

最近話題の代替案も含めて整理しておきました。
技術/アプローチ特徴メリットデメリット
Next.js (App Router)React Server Components を活用コンポーネント単位でのサーバー/クライアントの切り離しが可能学習コストが高く、複雑な状態管理が必要な場合がある
RemixWeb Standards に忠実な設計FormやActionによるデータ処理が直感的。Hydrationへの意識が比較的低いReactエコシステムとは異なる思考が必要な場面がある
TanStack Queryデータフェッチの統合Hydration後のクライアントでのデータ同期を強力にサポートする追加ライブラリの導入が必要。