Next.js ハイドレーションエラー事始め:もう「Text content does not match」に怯えない
開発サーバーのログを埋め尽くす、あの赤い文字。
Next.jsあるあるですね。
Error: Text content does not match server-rendered HTML.
Warning: PropclassNamedid not match. Server: "bg-blue-500" Client: "bg-red-500"
最初は「画面は見えてるし、まあいっか」とスルーしがちです。
しかしこのエラー、実はかなり危険です。ユーザーが一瞬だけ壊れたレイアウトを目撃したり(CLS)、最悪の場合、ボタンを押しても無反応な「文鎮化」状態を引き起こします。
今回は、単に suppressHydrationWarning で警告を揉み消すような対症療法ではなく、
「なぜハイドレーション(Hydration)はこんなにも繊細なのか」 という根本原理から、
現場で頻出する4つのNGパターンと、そのスマートな解決策を深掘りします。
そもそも、ハイドレーションって何してますのん?
敵を知るにはまず仕組みから。
従来のReact(CRA等のCSR)は、サーバーから空っぽの <body> だけ受け取って後からJSで描画していました。遅いしSEOも弱かったですよね。
対して Next.js (SSR) は親切です。
- サーバー:完成済みのHTML(
<h1>こんにちは</h1>)を作ってブラウザに投げます。 - 表示:ユーザーは0.1秒で文字を見ることができます(高速FCP)。
- ハイドレーション:その後、Reactが裏で起動し、「既にそこにあるHTML」にイベントリスナーなどの魂を吹き込みます。 これがハイドレーション(Hydration)です。
ここで絶対の掟があります。
「サーバーが送ったHTMLと、ブラウザで計算した初期UIは、1ビットたりともズレてはならない」
もしサーバーが「こんにちは」を送ったのに、ブラウザで計算したら「こんばんは」だった場合、Reactはパニックになります。
「このHTML、信用できない!」
といって、せっかくサーバーが描画した内容を破棄したり、エラーを吐いたりします。
現場でよく見る「4大・爆破パターン」
私が過去に踏んだ地雷たちです。
1. タイムトラベラー(時刻のズレ)
一番よくあるやつです。
export default function Footer() { // 💣 サーバーとクライアントで時間がズレる! return <footer>生成時刻: {new Date().toLocaleTimeString()}</footer>; }
- サーバー:10:00:00(Node.js環境での実行時)
- クライアント:10:00:01(ブラウザでの実行時)
当然、一致しません。エラー確定です。
Math.random() でIDを振るのも、乱数シードが揃わないのでNGです。
2. HTML構造がおかしい(pタグの中にdiv)
ブラウザは優しいのでHTMLが間違ってても直してくれますが、Reactは鬼軍曹です。
絶対にやってはいけないこと: <p> の中に <div> を入れる。
// ❌ Next.js激怒案件 <p> こんにちは <div>世界</div> </p>
HTMLの仕様上、<p>(段落)の中に <div>(ブロック)を入れることはできません。
ブラウザはこれを解釈する際、勝手に <p> を閉じてしまいます。
<!-- ブラウザの解釈 --> <p>こんにちは</p><div>世界</div><p></p>
しかしReactの仮想DOMは「いや、コードには中に入ってるって書いてある」と譲りません。
ここで現実(DOM)と理想(Virtual DOM)の乖離が起き、エラーが爆発します。
解決策:素直に <div> を使うか、インライン要素の <span> を使いましょう。
3. 第三者の介入(ブラウザ拡張機能)
「コードは完璧なのになぜかエラーが出る...」
犯人はChrome拡張機能かもしれません(Grammarly、翻訳ツール、Dark Readerなど)。
こやつらは、Reactがハイドレーションを完了するよりも速く、DOMに勝手に <span> タグとかを注入してきます。
Reactからすれば「誰だお前? 私が描画した覚えはないぞ」となります。
シークレットウィンドウで開いてエラーが消えるなら、拡張機能が犯人です(ユーザー環境で起きるのはどうしようもない側面もありますが、堅牢な作りは意識すべきです)。
4. うっかり window 参照
export default function Navbar() { // サーバー(Node.js)には window なんてないよ! const isMobile = window.innerWidth < 768; return <nav>{isMobile ? 'メニュー' : 'PC全画面'}</nav>; }
よくある「windowがない時はガードする」処理:
// サーバー:window無し(false) -> "PC全画面" をレンダリング // スマホ:window有り(true) -> "メニュー" をレンダリング const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
これもダメです。サーバーから届いたHTMLは「PC全画面」なのに、スマホで見ているユーザーのブラウザは「メニュー」を描画しようとします。不一致です。
これが正解! スマートな解決策 3選
1. useEffect でマウントを待つ(王道)
サーバーとクライアントで値が変わるもの(時刻、localStorage)は、Reactに正直に伝えます。
「最初はサーバーと同じ(無)を表示して、マウントしてから本当の値を出すね」
// hooks/useIsMounted.ts import { useState, useEffect } from 'react'; export function useIsMounted() { const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); }, []); return mounted; } // コンポーネント function Clock() { const isMounted = useIsMounted(); // 1. マウント前は何も表示しない(サーバーと一致させる) if (!isMounted) return null; // 2. マウント後(クライアント確定)に時刻を表示 return <span>{new Date().toLocaleTimeString()}</span>; }
2回レンダリングが走るコストはありますが、整合性を保つにはこれが最強です。
2. suppressHydrationWarning(見て見ぬふり)
「時刻が1秒ズレててもアプリは死なないから、警告消してくれ」という場合。
<span suppressHydrationWarning> {new Date().toLocaleTimeString()} </span>
これを付けると、Reactは「OK、ここのテキストの違いは目をつぶるわ」とスルーして、クライアントの値で上書きしてくれます。
注意:これはテキスト属性などの浅い階層にしか効きません。<body> とかに付けてレイアウト崩れをごまかすのはNGです。
3. dynamic import でSSR回避(Next.js奥義)
地図ライブラリとか、window 依存が激しいライブラリを使う場合。
SSRから除外してしまうのが手っ取り早いです。
import dynamic from 'next/dynamic'; // 「サーバーではレンダリングしないでね」 const MapComponent = dynamic(() => import('./Map'), { ssr: false, // ここがキモ loading: () => <p>地図読み込み中...</p>, }); export default function Page() { return <MapComponent />; }
サーバーは「読み込み中...」だけ送るので平和です。
まとめ
ハイドレーションエラーに出会ったら、焦らず以下のフローで確認しましょう。
- エラーを読む:
Expected div, found pならHTML構造ミス。 windowチェック:レンダリングロジックに入ってないか?useEffectに逃がす。- 拡張機能オフ:シークレットモードで確認。
- 最終手段:
suppressHydrationWarningかdynamic(ssr:false)。
SSRの爆速体験を享受するための税金みたいなものです。
仕組みさえわかれば怖くありません。良きNext.jsライフを!🚀