エラー内容
Error: Hydration failed because the initial UI does not match what was rendered on the server.
Warning: Expected server HTML to contain a matching <div> in <div>.
at div
at MyComponent
デプロイを実行し、ローカルでは問題なく動いていたのに、本番環境でクライアント側がハイドレーションエラーを投げている――あるいはさらに悪いことに、古いコンテンツが静かにレンダリングされている。ここでは原因を特定して潰す方法を解説する。
なぜ起きるのか
Reactはサーバー上でコンポーネントをレンダリングし、HTMLをブラウザに送信する。その後「ハイドレーション」が行われ、既存のDOMにイベントハンドラが紐付けられる。Reactが受け取るDOMと、自身がレンダリングするはずの内容が一致しない場合、ハイドレーションは失敗する。
原因の9割は以下のいずれかだ:
- レンダリング中にブラウザ専用API(
window、localStorage、navigator)にアクセスしている - サーバーとクライアントでわずかに異なるタイミングでレンダリングされる日付やタイムスタンプ
typeof window !== 'undefined'による条件分岐レンダリング- ブラウザ拡張機能(広告ブロッカー、パスワードマネージャー、翻訳ツール)によるDOMへのノード注入
- 不正なHTMLネスト――
<p>の中の<div>、または<p>の中の<p> - SSRに対応していないサードパーティコンポーネント
手順ごとの修正方法
手順1 — 不一致箇所を特定する
開発サーバーを起動し、ブラウザのコンソールを開く。完全なハイドレーションエラーを確認する――React 18は差分を正確に出力する:
pnpm dev
# or
npm run dev
何かに手を付ける前に、エラー出力を最後まで読むこと。コンポーネント名と要素名が明記されている。9割のケースで、すぐに該当ファイルに直行できる。
手順2 — レンダリング内のブラウザ専用コードを修正する
レンダリング時にwindowやlocalStorageにアクセスすることが、最も多い原因だ。サーバーにはこれらのAPIが存在しない。サイレントにクラッシュするかundefinedを返し、クライアントと出力が一致しなくなる。
誤り:
// localStorage doesn't exist on the server — this breaks SSR
export default function ThemeToggle() {
const theme = localStorage.getItem('theme') || 'light';
return <button>{theme}</button>;
}
修正 — useEffect内に移動する:
import { useState, useEffect } from 'react';
export default function ThemeToggle() {
const [theme, setTheme] = useState('light'); // SSR-safe default
useEffect(() => {
const saved = localStorage.getItem('theme');
if (saved) setTheme(saved);
}, []);
return <button>{theme}</button>;
}
useEffectはサーバー上では実行されない。サーバーと初回クライアントレンダリングはどちらも'light'で一致する。その後、ハイドレーション完了後にクライアントが実際の値に同期する。
手順3 — 日付・時刻レンダリングを修正する
タイムスタンプはよくあるトラップだ。サーバーがある時点でレンダリングし、クライアントが数ミリ秒後にレンダリングする――出力が異なり、ハイドレーションが失敗する。
// Wrong — new Date() on server != new Date() on client
<span>{new Date().toLocaleString()}</span>
// Fixed — render the date on the client only
function ClientDate() {
const [date, setDate] = useState('');
useEffect(() => {
setDate(new Date().toLocaleString());
}, []);
return <span>{date}</span>;
}
初回レンダリングは空文字列、マウント後に実際のタイムスタンプが表示される。不一致は発生しない。
手順4 — SSR非対応コンポーネントにはdynamic importを使う
マップライブラリ、canvasベースのチャート、インポート時にブラウザAPIを呼び出すコンポーネントなど、サーバーサイドで動かせないコンポーネントも存在する。無理に対応しようとせず、それらのSSRを完全にスキップする:
import dynamic from 'next/dynamic';
const MapComponent = dynamic(() => import('../components/Map'), {
ssr: false,
loading: () => <p>Loading map...</p>,
});
export default function Page() {
return <MapComponent />;
}
サーバーはローディングプレースホルダーを送信する。実際のコンポーネントは、ブラウザでJavaScriptが実行された後にのみ読み込まれる。明確な分離により、ハイドレーションの競合はゼロになる。
手順5 — 不正なHTMLネストを修正する
ブラウザは不正なHTMLをサイレントに自動修正する。Reactはしない。このギャップが、実際の問題とまったく無関係に見える不一致を引き起こす。
// Wrong — block element inside <p> is invalid HTML
<p>
<div>Some content</div>
</p>
// Fixed
<div>
<div>Some content</div>
</div>
構造的な問題が疑われる場合は、W3Cバリデーターでページを検証してみよう。ネストエラーを即座に検出できる。
手順6 — 条件付きレンダリングを正しく処理する
レンダリング中にtypeof windowで分岐すると、確実に不一致が発生する。サーバーはundefinedを見て、クライアントは実際のwindowオブジェクトを見る――それぞれ異なるHTMLを生成する。
// Wrong
function Banner() {
if (typeof window === 'undefined') return null;
return <div>Client only banner</div>;
}
// Fixed — use a mounted flag
function Banner() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return <div>Client only banner</div>;
}
手順7 — 避けられない不一致を抑制する
ブラウザ拡張機能は予測不能な要素だ。パスワードマネージャー、広告ブロッカー、翻訳ツールはDOMノードを注入してくるが、こちらには制御手段がない。そういったケースでは、コンテナレベルで警告を抑制する:
<div suppressHydrationWarning={true}>
{content}
</div>
いくつか注意点がある:これは1レベルの深さまでしか抑制されず、コードの実際のバグを修正するものではない。デバッグしていないエラーを黙らせる手段としてではなく、正当な外部DOMの変更に対してのみ使用すること。
修正の検証
- DevToolsコンソールを開く――ページロード時にハイドレーション警告がゼロであれば問題なし。
- JavaScriptを無効にしてリロードする。サーバーレンダリングされたHTMLが読み取り可能で、構造的に正しい状態であること。
- 本番ビルドを実行する:
pnpm build && pnpm start
本番モードでは、開発モードでは隠れていたハイドレーションの不一致が表面化することがある。リリース前には必ず本番ビルドでテストすること。
- React DevToolsプロファイラーを確認する――初回レンダリングツリーで予期しない再マウントが発生していないこと。
## 長期的にクリーンな状態を保つ
- **ユーザーではなくサーバー向けに状態を初期化する。**`useState`のデフォルト値はSSRに対して安全でなければならない。テーマ設定、認証状態、ロケールなどユーザー固有の情報は`useEffect`内に置くこと。
- **インストール前にサードパーティパッケージを監査する。**インポート時に`window`や`document`に触れるライブラリは、`dynamic(..., { ssr: false })`が必要だ。パッケージのREADMEやGitHubのイシューを確認しよう――誰かがすでに同じ問題に遭遇しているはずだ。
- **パーソナライズされたSSRコンテンツにはlocalStorageではなくCookieを使う。**ログイン状態、ロケール設定、`localStorage`からレンダリングされるA/Bテストのバリアントは不一致を引き起こす。代わりに`next/headers`を通じてCookieでサーバーサイドに渡すこと。
- **クリーンなブラウザプロファイルでテストする。**拡張機能が大量にインストールされた個人用ブラウザでは偽陽性が出やすい。ハイドレーションテスト専用に、拡張機能なしのChromeプロファイルを用意しておこう。

