⏰️

Next.jsのハイドレーションエラーを時計機能で理解する

に公開

はじめに

Next.js で時計機能を実装したとき、ハイドレーションエラーに遭遇しました。
この記事では、そのエラーの原因を深堀りし、Next.js の内部構造を理解していきます。

同じようなエラーで悩んでいる方の参考になれば幸いです。

エラーが発生した状況

発生したコード

天気情報アプリを作成していて、現在時刻を表示する時計機能を実装しました。

LiveTime.tsx(エラーが発生したコード)

//LiveTime.tsx
'use client';

import { useEffect, useState } from 'react';
import { formatDateToLocal, formatTimeToLocal } from '@/lib/utils';

export default function LiveTime() {
  const [currentTime, setCurrentTime] = useState(Date.now());

  useEffect(() => {
    const timer = setInterval(() => {
      setCurrentTime(Date.now());
    }, 1000);

    return () => clearInterval(timer);
  }, []);
  return (
    <div className="bg-white rounded-lg shadow-sm p-6 border border-gray-200 xl:flex-1">
      <div className="space-y-2">
        <div className="text-2xl md:text-3xl text-gray-900">
          {formatTimeToLocal(currentTime)}
        </div>
        <div className="text-3xl md:text-4xl text-gray-600">
          {formatDateToLocal(currentTime)}
        </div>
      </div>
    </div>
  );
}

Heading.tsx(親コンポーネント)

//Heading.tsx
'use client';

import SearchForm from './SearchForm';
import LiveTime from './LiveTime';

interface HeadingProps {
  onSearch: (city: string) => void;
}

export default function Heading({ onSearch }: HeadingProps) {
  return (
    <section className="space-y-6 lg:space-y-0 xl:flex xl:items-center xl:justify-between xl:gap-6">
      <LiveTime />
      <SearchForm onSearch={onSearch} />
    </section>
  );
}

一見問題なさそうに見えますが、このコードを実行するとエラーが発生。

発生したエラーメッセージ

開発サーバーを起動すると、コンソールに以下のようなエラーが表示されていました。

ハイドレーションエラーのスクリーンショット

真っ赤なエラーに戸惑いましたが、冷静に読み解くと

Uncaught Error: Hydration failed because the server rendered text didn't match the client.

意味は「サーバーでレンダリングされたテキストとクライアントが一致しなかったため、ハイドレーションに失敗した」

さらに詳しく見ると、HTML の差分が表示されていました。

<div className="bg-white r...">
  <div className="space-y-2">
    <div className="text-2xl md:text-3xl text-gray-900">
+      01:13:51
-      01:13:50

+-は、サーバーとクライアントで異なる値が生成されたことを示しています。
つまり:

  • サーバー側: 01:13:50
  • クライアント側: 01:13:51

わずか 1 秒のズレですが、これが原因でエラーになっていた。
なぜ同じコードなのに、サーバーとクライアントで違う値が生成されるのか?

なぜエラーが起きたのか?

エラーの原因を理解するには、Next.js の SSR とハイドレーションの仕組みを知る必要があります。

Next.js の SSR とは?

SSR とはServer-side Renderingの略です。
Next.js ではページがリクエストされると、サーバー側で事前に HTML を生成して返却します。
この仕組みをPre-renderingといい、SSR はその方式のひとつです。

重要な注意点
Next.js では、Client Component(use clientがついたコンポーネント)も初回アクセス時はサーバー側で Pre-rendering されます。
多くの初学者はこの点を勘違いをしていると思います。
もちろん僕も勘違いをしていました。

Next.js では、ページがリクエストされると

  1. サーバー側で React コンポーネントを実行
  2. サーバー側で HTML を生成
  3. 生成された HTML をブラウザに返却

この仕組みにより、ユーザーは素早くページの内容を見ることができます。
しかし、この時点ではまだ静的な HTMLなので、ボタンクリックなどのインタラクティブな機能は動きません。また、windowdocumentなどのブラウザ API にもアクセスできません。
ここで登場するのが「ハイドレーション」です。

ハイドレーションとは?

ハイドレーション(Hydration)とは、サーバーで生成された静的な HTML を表示した後、JavaScript をダウンロードして実行するというプロセスです。

具体的な流れ

  1. HTML を表示 - サーバーから受け取った HTML を表示
  2. JavaScript をダウンロード - コンポーネントのコードがブラウザにダウンロードされる。
  3. JavaScript を実行 - React がコンポーネントを再実行
  4. DOM に紐づけ - 既存の HTML にイベントリスナーや状態管理を紐付ける

この時、サーバーで生成された HTML と、クライアントで実行された React の結果が一致している必要があります。
もし一致しなければ、ハイドレーションエラーが発生します。
では、先程のLiveTimeコンポーネントで何が起きていたのでしょうか?

時刻のズレが問題になる理由

ここまでの知識を踏まえて、先ほどのコードでなにが起きていたのか見ていきます。

問題のコード

//LiveTimer.tsx
const [currentTime, setCurrentTime] = useState(Date.now());

エラーが起きるまでの流れ

1. サーバー側でのレンダリング(SSR)

LiveTimeコンポーネントがサーバーで実行されます。

//LiveTimer.tsx
const [currentTime, setCurrentTime] = useState(Date.now());
//Date.now()が実行される → 例: 732374830000 (01:13:50)

このタイムスタンプを元に HTML が生成されます

01:13:50

2. クライアント側でのハイドレーション

ブラウザが HTML を受け取り、JavaScript をダウンロードして実行します。

再度LiveTimeコンポーネントが実行される

//LiveTime.tsx
const [currentTime, setCurrentTime] = useState(Date.now());
//Date.now()がまた実行される → 例: 1732374831000 (01:13:51)

サーバーでの実行から数秒経過しているため、時刻が異なります

3. React/Next.js の比較

ハイドレーション時、React は以下を比較します

サーバーが生成したHTML: <div>01:13:50</div>
クライアントが生成したHTML: <div>01:13:51</div>

一致しない! → ハイドレーションエラーが発生

Date.now()実行された瞬間の時刻を返します。

  • サーバーでの実行: 2025 年 11 月 24 日 01:13:50
  • クライアントでの実行: 2025 年 11 月 24 日 01:13:51(わずか 1 秒後)

この実行タイミングの違いが、ハイドレーションエラーの原因でした。

React/Next.js は、サーバーとクライアントで同じ結果を期待していますが、Date.now()のような実行ごとに変わる値を使うと、この期待が裏切られてしまいます。

解決方法

方法 1: クライアント側の初期値をサーバー側に合わせる

推奨される方法はuseEffectを使ってクライアント側での初期値をサーバー側に合わせることです。

実装

//LiveTime.tsx
'use client';

import { useEffect, useState } from 'react';
import { formatDateToLocal, formatTimeToLocal } from '@/lib/utils';

export default function LiveTime() {
  const [currentTime, setCurrentTime] = useState<number | null>(null);

  useEffect(() => {
    // 初回実行に即座にセット
    setCurrentTime(Date.now());

    const timer = setInterval(() => {
      setCurrentTime(Date.now());
    }, 1000);

    return () => clearInterval(timer);
  }, []);
  if (currentTime === null) {
    return (
      <div className="bg-white rounded-lg shadow-sm p-6 border border-gray-200 xl:flex-1">
        <div className="space-y-2">
          <div className="text-sm md:text-base text-gray-900">
            読み込み中...
          </div>
        </div>
      </div>
    );
  }

  return (
    <div className="bg-white rounded-lg shadow-sm p-6 border border-gray-200 xl:flex-1">
      <div className="space-y-2">
        <div className="text-2xl md:text-3xl text-gray-900">
          {formatTimeToLocal(currentTime)}
        </div>
        <div className="text-3xl md:text-4xl text-gray-600">
          {formatDateToLocal(currentTime)}
        </div>
      </div>
    </div>
  );
}

仕組み

  1. サーバー側currentTimenull → 「読み込み中...」を HTML に生成
  2. クライアント側(ハイドレーション時): 同じくnull → 「読み込み中...」
  3. 一致! → ハイドレーションエラーなし
  4. useEffect 実行: Date.now()で時刻をセット → 正しい時刻に更新

メリット

  • SSR の恩恵を受けられる(初回 HTML が生成される)
  • SEO に影響しない
  • TypeScript の型安全性も保たれる

デメリット

  • 一瞬「読み込み中...」が表示される
  • コンポーネントのロジックを変更する必要がある

方法 2: dynamic で SSR を無効化

next/dynamicを使って該当コンポーネントを SSR しないようにする方法です。
僕はこちらの方法を採用しました。

実装

親コンポーネントでdynamicインポートを使います

//Heading.tsx
'use client';

import SearchForm from './SearchForm';
import dynamic from 'next/dynamic';

//LiveTimeコンポーネントをdynamicインポート
//{ ssr: false }でSSRを無効化
const LiveTime = dynamic(() => import('./LiveTime'), { ssr: false });

interface HeadingProps {
  onSearch: (city: string) => void;
}

export default function Heading({ onSearch }: HeadingProps) {
  return (
    <section className="space-y-6 lg:space-y-0 xl:flex xl:items-center xl:justify-between xl:gap-6">
      <LiveTime />
      <SearchForm onSearch={onSearch} />
    </section>
  );
}

LiveTimeコンポーネント自体は変更不要です。

仕組み

  1. サーバー側:{ ssr: false }によりレンダリングされない → HTML には何も生成されない
  2. **クライアント側:**JavaScript ダウンロード後にLiveTimeが初回実行される
  3. ハイドレーション自体が発生しない → エラーも発生しない

メリット

  • コンポーネントのロジックを変更する必要がない(シンプル)
  • 確実にエラーを回避できる
  • loadingプロパティでローディング表示を指定できる

デメリット

  • SSR の恩恵を受けられない(初回 HTML に含まれない)
  • 一瞬空白やスケルトンが表示される
  • SEO に影響する可能性がある(検索エンジンがコンテンツを認識できない)

この方法が適している場合

  • 補助的な UI(時計、ユーザー固有の情報など)
  • ファーストビュー外のコンテンツ
  • SEO が重要でないコンテンツ
  • スケルトンローディングで UX をカバーできる場合

僕がこの方法を選んだ理由

時計機能は補助的な UI で SEO への影響がなく、スケルトンで UX もカバーできたためです。
ただし、ブログ記事や商品説明など、SEO が重要なコンテンツには方法 1 を使うべきです。

方法 3: suppressHydrationWarning で警告を無視

suppressHydrationWarningを使ってハイドレーションエラーを無視する方法です。

この方法は最終手段です。

実装

//LiveTime.tsx
'use client';

import { useEffect, useState } from 'react';
import { formatDateToLocal, formatTimeToLocal } from '@/lib/utils';

export default function LiveTime() {
  const [currentTime, setCurrentTime] = useState(Date.now());

  useEffect(() => {
    const timer = setInterval(() => {
      setCurrentTime(Date.now());
    }, 1000);
    return () => clearInterval(timer);
  }, []);

  return (
    <div className="bg-white rounded-lg shadow-sm p-6 border border-gray-200 xl:flex-1">
      {/* suppressHydrationWarningを追加 */}
      <div className="space-y-2" suppressHydrationWarning>
        <div className="text-2xl md:text-3xl text-gray-900">
          {formatTimeToLocal(currentTime)}
        </div>
        <div className="text-3xl md:text-4xl text-gray-600">
          {formatDateToLocal(currentTime)}
        </div>
      </div>
    </div>
  );
}

仕組み

ハイドレーションエラーを無視するだけです。根本的な解決にはなりません。

メリット

  • コード変更が最小限

デメリット

  • エラーを隠すだけで解決していない
  • 一瞬サーバー側の時刻が表示される(ちらつき)
  • デバッグが困難になる可能性

注意点

suppressHydrationWarningは 1 階層のみ有効です。子要素にエラーがある場合は、その要素に直接指定する必要があります。

本当にどうしようもない場合以外は使用を避けましょう。

学びと応用

この経験から理解できたこと

このハイドレーションエラーを通じて、以下のことが理解できました。

  1. Next.js の SSR の仕組み - Client Component もサーバー側で Pre-rendering される
  2. ハイドレーションの役割 - 静的な HTML に JavaScript を紐付けるプロセス
  3. サーバーとクライアントの違い - 実行タイミングや環境の違いを意識する重要性

他にもハイドレーションが起きるケース

Date.now()以外にも、サーバーとクライアントで異なる値を生成するコードは、ハイドレーションエラーを引き起こします。

典型的な例:

  • Math.random()などのランダム値生成
  • localStoragesessionStorageなどのブラウザストレージ
  • window.innerWidthなどのブラウザ API
  • UUID 生成ライブラリ

共通点: 実行ごとに異なる値を返す、またはブラウザ環境でしか動作しないコードです。

まとめ

この記事を書きながら、一番興奮したのは Next.js の内部構造が理解できた瞬間でした。

今までは Next.js のHowにしか触れてきませんでした。しかし今回、「なぜ事前にレンダリングされるのか?」「サーバーとクライアントの両方でレンダリングしたら、ハイドレーションエラーが起きるのは必然なのに、なぜそんな仕組みにしているのか?」という疑問を深掘りすることで、MPA と SPA の違いや、Web アプリ開発の変遷が見えてきました。

静的 HTML の時代 → SPA(CSR)の登場 → その課題を解決する SSR → そして RSC。

こうした歴史があり、その当時の問題を解決した結果として、現在の Next.js/React が存在しています。

Why を深掘りすることで、フレームワークの本質が理解できる。

エラーに遭遇したとき、表面的な解決方法だけでなく、「なぜこのエラーが起きるのか?」を理解することで、技術への理解が深まり、応用力が身につくと実感しました。
まだまだ知らないことばかりですが、これからも Why を追求していきたいです。

同じようにハイドレーションエラーで悩んでいる方、Next.js の内部構造を理解したい方の参考になれば幸いです。

参考にした記事

この記事を書く上で、以下の記事を参考にさせていただきました。

https://zenn.dev/noko_noko/articles/7987456909978c

https://zenn.dev/luvmini511/articles/71f65df05716ca

https://zenn.dev/ak/articles/dd60f8b1712628

https://speakerdeck.com/recruitengineers/react-2023?slide=4

https://nextjs.org/docs/app/building-your-application/rendering/server-components

https://react.dev/reference/react-dom/client/hydrateRoot

GitHubで編集を提案

Discussion