React SSR Hydration Mismatch 原因と修正|Next.js・Astro対応ガイド
- Hydration mismatch(SSR ハイドレーション エラー)の大半は、サーバーとクライアントで異なる値を JSX に直接埋め込んでいることが原因だ。
Date・Math.random()・window参照は useEffect でマウント後に描画するか、Next.js のdynamic(..., { ssr: false })/ Astro のclient:onlyで SSR から外す。suppressHydrationWarningは<time>の時刻表示など意図的な差異にだけ使う。- 表示のちらつきや LCP への影響は クリティカルレンダリングパス最適化 とセットで計測しよう。
Hydration mismatch(ハイドレーション不一致)とは
Hydration(ハイドレーション) とは、サーバーが返した HTML に対して、クライアント側の React がイベントリスナーと内部状態を「注水」してインタラクティブにする処理です。SSR(Server-Side Rendering)や SSG(Static Site Generation)を使う Next.js・Remix・Astro(React 島)では、最初の HTML はサーバー(またはビルド時)が生成し、ブラウザ到着後に同じ UI 構造を React が再現できることが前提になります。
Hydration mismatch は、その再現に失敗した状態です。React 18 以降、開発モードのコンソールには次のようなメッセージが出ます。
Warning: Text content did not match. Server: "2026/06/21 10:00:00" Client: "2026/06/21 19:00:00"
Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.
Error: Hydration failed because the initial UI does not match what was rendered on the server.
不一致が起きると React は該当サブツリーをクライアント側で捨てて再レンダーします(recovery)。ページは動いて見えても、次の問題が残ります。
- ちらつき(FOUC 的な再描画) — ユーザーが一瞬サーバー HTML を見た直後に内容が入れ替わる
- パフォーマンス低下 — せっかく送った HTML の再利用ができず、クリティカルレンダリングパス上の FCP・LCP のメリットが薄れる
- イベントの一瞬の不整合 — 稀に hydration 直後のクリックが効かないケース
- 本番でも recover コスト — 開発モードほどうるさくないだけで、本番でも不一致は修正コストが発生する
SSR の流れを整理すると、次のようになります。
sequenceDiagram
participant Server as サーバー / ビルド
participant Browser as ブラウザ
participant React as React (client)
Server->>Browser: HTML + CSS 配信
Note over Browser: First Paint(CRP)
Browser->>React: JS バンドル読み込み・実行
React->>React: サーバー HTML と仮想 DOM を照合
alt 一致
React->>Browser: イベント接続のみ(理想)
else mismatch
React->>Browser: サブツリーを破棄して client render
end
Hydration は CRP の後半 — HTML が届いて First Paint したあと — で走ります。不一致による再レンダーは、LCP 要素が React 管理下にある場合に計測値を悪化させることがあります。パフォーマンス改善とセットで hydration を潰す理由はここにあります。
エラーメッセージの読み方
コンソールメッセージから原因カテゴリを絞れます。
| メッセージ断片 | よくある原因 |
|---|---|
| Text content did not match | Date フォーマット、乱数、locale 依存文字列、翻訳拡張によるテキスト改変 |
Prop %s did not match | id/className/style に random や Date.getTime() を使っている |
Expected server HTML to contain a matching <td> in <table> | ブラウザが自動補正した invalid HTML(<p> 内 <div> 等) |
| There was an error while hydrating | 上記のいずれか、またはサードパーティ UI ライブラリの SSR 非対応 |
| Hydration failed because the initial UI does not match | App Router で Server/Client 境界の props 不一致、条件分岐の差 |
React 19 ではメッセージ文言が多少変わる場合がありますが、Server と Client の差分を示す2つの値が必ずどこかに出ます。その2つを見比べるのが最短の診断です。
原因1:Date とタイムゾーン
最頻出の hydration mismatch 原因が new Date() 系の呼び出しです。
問題コード
// ❌ サーバー(UTC/データセンター TZ)とクライアント(ユーザー TZ)で文字列が変わる
export function PublishedAt({ iso }: { iso: string }) {
const formatted = new Date(iso).toLocaleString('ja-JP');
return <span>{formatted}</span>;
}
// ❌ 現在時刻を SSR 中に描画
export function Clock() {
return <span>{new Date().toLocaleTimeString()}</span>;
}
サーバーが 2026/06/21 10:00:00、クライアントが 2026/06/21 19:00:00 のように、タイムゾーン差だけで mismatch になります。ビルド時 SSG なら「数時間前のビルド時刻 vs 閲覧時刻」の差も付きます。
修正パターンA:useEffect でクライアント専用描画
'use client';
import { useEffect, useState } from 'react';
export function ClientLocalDate({ iso }: { iso: string }) {
const [text, setText] = useState<string>('');
useEffect(() => {
setText(new Date(iso).toLocaleString('ja-JP'));
}, [iso]);
// SSR 時は空または固定フォーマット(UTC)を表示
return <time dateTime={iso}>{text || iso.slice(0, 10)}</time>;
}
SSR 時は ISO 日付の先頭10文字(2026-06-21)だけ見せ、マウント後に locale 整形するのが UX と hydration のバランスが良い定石です。
修正パターンB:サーバーで UTC 固定 + suppressHydrationWarning
秒単位で動く時計など、意図的にサーバーとクライアントが異なる場合は <time> と suppressHydrationWarning を組み合わせます。
export function LiveClock({ serverNow }: { serverNow: string }) {
return (
<time dateTime={serverNow} suppressHydrationWarning>
{new Date(serverNow).toLocaleTimeString('ja-JP')}
</time>
);
}
suppressHydrationWarning はその要素の直接のテキスト/属性だけを対象にし、子ツリーまでは覆いません。乱用しない — まずは useEffect パターンを検討してください。
修正パターンC:Intl.DateTimeFormat をサーバーと同じ locale/TZ で
どうしても SSR で整形するなら、サーバーとクライアントで同じ timeZone を明示します。
const formatter = new Intl.DateTimeFormat('ja-JP', {
timeZone: 'Asia/Tokyo',
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
export function JstDate({ iso }: { iso: string }) {
return <time dateTime={iso}>{formatter.format(new Date(iso))}</time>;
}
Node とブラウザ両方で Asia/Tokyo を固定すれば一致します。ユーザーのローカル TZ に合わせたいなら結局クライアント描画が必要です。
原因2:Math.random()・UUID・ハッシュ
非決定的な値を id・key・className に使うと、SSR と CSR で別物になります。
問題コード
// ❌ 毎レンダーで id が変わる
export function Tooltip({ label }: { label: string }) {
const id = `tip-${Math.random().toString(36).slice(2)}`;
return (
<>
<button aria-describedby={id}>{label}</button>
<span id={id} role="tooltip">説明文</span>
</>
);
}
// ❌ crypto.randomUUID() も SSR/CSR で別 UUID
export function Row({ label }: { label: string }) {
return (
<tr key={crypto.randomUUID()}>
<td>{label}</td>
</tr>
);
}
修正:安定 id を props または useId で
'use client';
import { useId } from 'react';
export function Tooltip({ label }: { label: string }) {
const id = useId(); // React 18+: SSR/CSR で同じ id を生成
return (
<>
<button aria-describedby={id}>{label}</button>
<span id={id} role="tooltip">説明文</span>
</>
);
}
useId() は React 18 以降の公式 API で、プレフィックス付きの安定 id を SSR と CSR で一致させます。ランダム UUID が必要なら、サーバーで1回生成して props で渡すか、クライアントマウント後だけ生成してください。
'use client';
import { useEffect, useState } from 'react';
export function ClientUuid({ children }: { children: (id: string) => React.ReactNode }) {
const [id, setId] = useState<string | null>(null);
useEffect(() => {
setId(crypto.randomUUID());
}, []);
if (!id) return null; // または skeleton
return <>{children(id)}</>;
}
原因3:window・localStorage・メディアクエリ
ブラウザ専用 API をレンダー中に参照すると、サーバーでは undefined、クライアントでは値あり — という分岐が起きます。
問題コード
// ❌ サーバーでは window が存在しない
export function ThemeBadge() {
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
return <span>{dark ? 'Dark' : 'Light'}</span>;
}
// ❌ localStorage は SSR 不可
export function Greeting() {
const name = localStorage.getItem('name') ?? 'Guest';
return <p>Hello, {name}</p>;
}
修正:二段階レンダー(mounted フラグ)
'use client';
import { useEffect, useState } from 'react';
export function ThemeBadge() {
const [dark, setDark] = useState<boolean | null>(null);
useEffect(() => {
setDark(window.matchMedia('(prefers-color-scheme: dark)').matches);
}, []);
if (dark === null) {
return <span aria-hidden="true">…</span>; // または CSS @media のみで表現
}
return <span>{dark ? 'Dark' : 'Light'}</span>;
}
より良い代替:テーマは class="dark" を <html> に付与し、CSS だけで見た目を切り替え、React state に依存しない。Tailwind の dark: 修飾子は hydration mismatch を起こしにくいです。
// HTML 側(Next.js layout 等)で class を付け、見た目は CSS のみ
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ja" className="dark">
<body className="bg-white text-zinc-900 dark:bg-zinc-900 dark:text-zinc-100">
{children}
</body>
</html>
);
}
原因4:ブラウザ拡張機能
アプリのコードが正しくても hydration mismatch が出る典型例がブラウザ拡張です。
| 拡張の種類 | DOM への介入例 |
|---|---|
| Grammarly | <grammarly-extension> や追加属性を textarea 周辺に注入 |
| 翻訳(Google 翻訳等) | テキストノードを <font> でラップ |
| パスワードマネージャ | input に data-* 属性、見えない iframe を追加 |
| 広告ブロッカー | 特定 class 名の要素を削除・非表示 |
| React DevTools 以外の開発系 | 独自 overlay DOM |
切り分け手順:
- シークレット / プライベートウィンドウ(拡張オフ)で再現するか
- 別ブラウザで再現するか
- 不一致ノードの HTML を Elements パネルで見て、未知の属性・要素がないか
拡張起因はアプリ側で完全には防げません。重要なのは自前コードの mismatch をゼロにすること。拡張による recover は許容範囲とし、E2E テストは拡張なし環境を前提にします。
原因5:Invalid HTML とブラウザの自動補正
React はサーバー出力 HTML を厳密に期待しますが、ブラウザは壊れた HTML を勝手に直すため、hydration 前後で木構造が変わることがあります。
問題コード
// ❌ <p> の中に <div> は HTML 仕様違反(ブラウザが </p> を早く閉じる)
export function Article() {
return (
<p>
前文
<div>ブロック要素</div>
</p>
);
}
// ❌ <table> 直下に <tr> だけ(tbody が暗黙挿入される)
export function BadTable() {
return (
<table>
<tr><td>cell</td></tr>
</table>
);
}
修正:セマンティックに正しい構造
export function Article() {
return (
<>
<p>前文</p>
<div>ブロック要素</div>
</>
);
}
export function GoodTable() {
return (
<table>
<tbody>
<tr><td>cell</td></tr>
</tbody>
</table>
);
}
Markdown → HTML 変換パイプラインや CMS リッチテキストが <p><div>...</div></p> を生成していないかも確認対象です。
原因6:条件分岐の typeof window !== 'undefined'
古いコードベースに残りがちなパターンです。
// ❌ サーバーとクライアントで JSX 構造自体が変わる
export function MapLink({ lat, lng }: { lat: number; lng: number }) {
if (typeof window === 'undefined') {
return <p>地図を読み込み中...</p>;
}
return <iframe src={`https://maps.example/?lat=${lat}&lng=${lng}`} />;
}
サーバーは <p>、クライアントは <iframe> — タグ名不一致で確実に hydration failed になります。
修正:同じ骨格 + クライアントで中身を差し替え
'use client';
import dynamic from 'next/dynamic';
import { useEffect, useState } from 'react';
const MapIframe = dynamic(() => import('./MapIframe'), { ssr: false });
export function MapLink(props: { lat: number; lng: number }) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) {
return <div className="map-placeholder">地図を読み込み中...</div>;
}
return <MapIframe {...props} />;
}
骨格(ラッパー div)は SSR/CSR で共通にし、中身だけ client-only にするのがポイントです。
useEffect パターン — クライアント専用描画の定石
多くの hydration mismatch は、次の mounted フラグパターンで解決します。
'use client';
import { useEffect, useState } from 'react';
export function useIsClient() {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
return isClient;
}
export function ClientOnly({ children, fallback = null }: {
children: React.ReactNode;
fallback?: React.ReactNode;
}) {
const isClient = useIsClient();
if (!isClient) return fallback;
return <>{children}</>;
}
使い方:
<ClientOnly fallback={<Skeleton />}>
<Chart data={data} />
</ClientOnly>
注意点:
- fallback のレイアウトは本番 UI と同じサイズにする(CLS 防止。CRP と Core Web Vitals参照)
useEffectは Paint 後に走るため、一瞬 fallback が見える — 重要 UI なら CSS で同等のプレースホルダを- Server Component 内に
useEffectは書けない(Next.js App Router)。Client 子コンポーネントに切り出す
suppressHydrationWarning を使うべき場面
suppressHydrationWarning={true} は React がその要素のテキスト/属性不一致警告を抑制する属性です。
使ってよい例
<time>要素のローカル時刻表示(サーバー UTC vs クライアント local)<html lang>周辺で locale プラグインが付与する属性差(稀)- 外部ウィジェットが1属性だけサーバー/クライアントで異なる既知ケース
<time dateTime={iso} suppressHydrationWarning>
{display}
</time>
使ってはいけない例
Math.random()やkey不一致の「隠蔽」- invalid HTML の警告握りつぶし
- 原因調査前のとりあえず付与
suppressHydrationWarning は治療ではなく麻酔です。不一致の理由を理解したうえで、最小スコープに限定してください。
Next.js での修正
Next.js 13+ App Router と Pages Router それぞれの要点です。
App Router:use client 境界
Server Component では useState / useEffect / ブラウザ API が使えません。環境依存 UI は Client Component に分離します。
// app/dashboard/page.tsx — Server Component
import { ClientClock } from './ClientClock';
export default function Page() {
return (
<main>
<h1>Dashboard</h1>
<ClientClock />
</main>
);
}
// app/dashboard/ClientClock.tsx
'use client';
import { useEffect, useState } from 'react';
export function ClientClock() {
const [now, setNow] = useState<string>('');
useEffect(() => {
setNow(new Date().toLocaleTimeString('ja-JP'));
const id = setInterval(() => {
setNow(new Date().toLocaleTimeString('ja-JP'));
}, 1000);
return () => clearInterval(id);
}, []);
return <time suppressHydrationWarning>{now || '—'}</time>;
}
next/dynamic で SSR スキップ
地図・エディタ・Three.js など SSR 非対応ライブラリは dynamic import が定石です。
'use client';
import dynamic from 'next/dynamic';
const RichEditor = dynamic(() => import('./RichEditor'), {
ssr: false,
loading: () => <p>エディタを読み込み中...</p>,
});
export function EditorPage() {
return <RichEditor />;
}
Pages Router でも同じ API が使えます。
next-themes と hydration
ダークモード切替で mismatch が出やすいです。next-themes は内部で mounted チェックと suppressHydrationWarning を使います。自前実装するなら同様のパターンを踏襲してください。
// layout.tsx
import { ThemeProvider } from 'next-themes';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ja" suppressHydrationWarning>
<body>
<ThemeProvider attribute="class" defaultTheme="system">
{children}
</ThemeProvider>
</body>
</html>
);
}
<html suppressHydrationWarning> は theme class 付与による class 属性差を許容するためのフレームワーク慣習です。プロジェクト全体に付けるのではなく、next-themes 等のドキュメントに従う範囲に留めます。
Server Component から Client へ渡す props
Date オブジェクト・関数・Symbol は Server → Client の境界を越えられません。ISO 文字列などシリアライズ可能な形にします。props の不一致も hydration エラーの温床です。
// ✅ Server
<ClientChart data={rows.map(r => ({ ...r, createdAt: r.createdAt.toISOString() }))} />
// ❌ Date をそのまま渡す
<ClientChart data={rows} />
Astro での修正
Astro はデフォルトで ゼロ JS の HTML を配信し、React Vue Svelte 等は島(islands)として hydrate します。このサイトも Astro ベースであり、Content Collections で MDX を管理しています。
島の hydrate ディレクティブ
| ディレクティブ | 動作 | mismatch リスク |
|---|---|---|
client:load | ページ読み込み直後に hydrate | 中 — SSR HTML あり |
client:idle | requestIdleCallback 後 | 中 |
client:visible | ビューポート進入時 | 低〜中 |
client:only="react" | SSR スキップ、クライアントのみ | なし(HTML 不一致自体が起きない) |
環境依存コンポーネントは client:only が最短です。
---
// src/pages/dashboard.astro
import Chart from '../components/Chart.tsx';
---
<Layout>
<h1>Analytics</h1>
<!-- SSR しないので hydration mismatch なし -->
<Chart client:only="react" data={serialized} />
</Layout>
client:only の代償は 初期 HTML にコンポーネント内容がないこと。LCP 要素にしない、または skeleton を Astro 側で静的に置きます。
Astro + React:client:load 時の注意
client:load では Astro がサーバーで React を一度 renderToString します。Date / random / window の問題は Next.js と同じです。修正も useEffect パターンが共通です。
// Chart.tsx — Astro から client:load で呼ばれる
import { useEffect, useState } from 'react';
export default function Chart({ data }: { data: string }) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) {
return <div className="h-64 bg-zinc-100 animate-pulse" aria-hidden="true" />;
}
const parsed = JSON.parse(data) as { labels: string[]; values: number[] };
return (
<canvas
aria-label="売上チャート"
width={640}
height={256}
data-labels={parsed.labels.join(',')}
data-values={parsed.values.join(',')}
/>
);
}
props のシリアライズ
Astro → React 島へ渡す props は JSON 化可能である必要があります。undefined・関数・クラスインスタンスは渡せません。Date は ISO 文字列にして Client 側で parse します。
診断ワークフロー
コンソールで Server/Client の不一致値をメモする
該当コンポーネントを React DevTools Components タブで特定する
grep で Date|random|localStorage|window|typeof window を検索する
シークレットウィンドウで拡張起因か切り分ける
useEffect / dynamic ssr:false / client:only のいずれかで修正する
修正後 Lighthouse で CLS・LCP を再計測する
再現を安定させる
next devとnext build && next startの両方で確認(開発モードだけ extra check がある)- 本番ビルドでしか出ない mismatch は、SSG のビルド時刻 vs 実行時刻が典型
- Storybook の SSR addon でサーバー HTML を snapshot 比較する方法も有効
実務チェックリスト
本番 merge 前に確認したい項目です。
- JSX 内に
new Date()/Date.now()を直接書いていない -
Math.random()/crypto.randomUUID()を render 中に使っていない —useId()または server 生成 id -
window/document/localStorageを render phase で触っていない -
typeof windowで 異なる JSX ツリーを返していない - HTML ネストが仕様準拠(p 内 div、table 構造等)
- サードパーティ UI が SSR 対応か — 非対応なら dynamic / client:only
- Server → Client props が JSON シリアライズ可能
-
suppressHydrationWarningは<time>等の最小限のみ - シークレットウィンドウで拡張起因でないことを確認
パフォーマンスとの関係
Hydration 自体がメインスレッドコストです。不一致による client 再 render はそのコストをさらに積み上げます。初期表示のボトルネック分析は クリティカルレンダリングパス最適化 の FCP・LCP フレームワークと組み合わせてください。
特に次の点に注意します。
- LCP 要素を Client-only にしない — ヒーロー画像・見出しは Server/SSG HTML に含める
- client:only / ssr:false のスケルトンに width/height を指定し CLS を防ぐ
- 過度な client:load 島 — ページ全体を React 化すると hydration コストが増える。静的 HTML + 必要部分だけ島が Astro の強み
よくある Q&A(実装編)
Strict Mode で二重に warning が出る
React 18 Strict Mode(開発)では useEffect が二回呼ばれ、副作用のバグを検出します。hydration warning とは別問題ですが、コンソールがノイズだらけになることはあります。hydration 修正後も残る二重 mount ログは Strict Mode 固有と切り分けてください。
dangerouslySetInnerHTML と mismatch
サーバー HTML 文字列とクライアント生成文字列が1文字でも違うと mismatch です。Markdown レンダラのバージョン差、sanitize タイミング差(server のみ DOMPurify)も疑います。XSS 対策の文脈では XSS 防御ガイド も参照してください。
Remix / React Router SSR
Loader で取得したデータは SSR と CSR で同じである必要があります。Loader が Cookie や User-Agent で分岐すると mismatch の原因になります。データは loader、UI 分岐は Client — の分離が有効です。
テストで hydration を検出する
Playwright で page.on('console') を監視し、Hydration を含むメッセージを assert 失敗にできます。
test('no hydration errors', async ({ page }) => {
const errors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error' && /hydration/i.test(msg.text())) {
errors.push(msg.text());
}
});
await page.goto('/');
expect(errors).toEqual([]);
});
React 19 と 2026 年の SSR パターン
2026 年時点の主要フレームワーク(Next.js 15+、Astro 5、React 19)は、hydration の扱いが少しずつ進化しています。根本原則 — サーバーとクライアントで同じ JSX 出力を保つ — は変わりませんが、実装の選択肢が増えています。
React 19 の use と Suspense 境界
React 19 では Server Component から Promise を use() で読むパターンが一般化しました。クライアント側の hydration mismatch とは別軸ですが、Client Component に非同期データを渡すタイミングで props の中身がズレると、初回 CSR と SSR HTML の差が出ます。
// app/posts/[id]/page.tsx — Server Component
import { Suspense } from 'react';
import { PostBody } from './PostBody';
async function getPost(id: string) {
const res = await fetch(`https://api.example.com/posts/${id}`, {
next: { revalidate: 60 },
});
return res.json() as Promise<{ title: string; body: string; publishedAt: string }>;
}
export default async function PostPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const post = await getPost(id);
return (
<article>
<h1>{post.title}</h1>
<Suspense fallback={<p>本文を読み込み中</p>}>
<PostBody publishedAt={post.publishedAt} html={post.body} />
</Suspense>
</article>
);
}
'use client';
// PostBody.tsx — 日付は ISO 文字列で受け取り、表示だけ Client で
import { useEffect, useState } from 'react';
export function PostBody({ publishedAt, html }: { publishedAt: string; html: string }) {
const [displayDate, setDisplayDate] = useState(publishedAt.slice(0, 10));
useEffect(() => {
setDisplayDate(new Date(publishedAt).toLocaleDateString('ja-JP'));
}, [publishedAt]);
return (
<footer>
<time dateTime={publishedAt} suppressHydrationWarning>
{displayDate}
</time>
<div dangerouslySetInnerHTML={{ __html: html }} />
</footer>
);
}
dangerouslySetInnerHTML を使う場合、サーバーとクライアントで同じ sanitize パイプラインを通す必要があります。片方だけ DOMPurify をかけると HTML 文字列が1文字でも違えば mismatch になります。サニタイズ漏れは XSS 防御ガイド で扱っている多層防御と直結するため、hydration 修正とセットで監査してください。
Partial Prerendering(PPR)と島アーキテクチャ
Next.js の Partial Prerendering(PPR)と Astro の島モデルは、どちらも静的シェル + 動的穴という思想です。静的シェル部分はビルド時 HTML が確定し hydration mismatch が起きにくく、動的穴は Suspense や client:only で明示的に分離します。
| 方式 | 静的シェル | 動的穴の典型 | mismatch リスク |
|---|---|---|---|
| Astro 島 | .astro テンプレート | client:only / client:visible | 島内の React コード次第 |
| Next.js PPR | プリレンダーされた layout | Suspense 内の dynamic import | Client 子の環境依存コード |
| 従来 SSR | リクエストごとに全 HTML | ページ全体が対象 | 影響範囲が最大 |
2026 年の新規プロジェクトでは、LCP 要素は静的シェルに置き、時刻・乱数・localStorage 依存 UI は穴に追いやる設計が定石です。パフォーマンス計測は クリティカルレンダリングパス最適化 の FCP・LCP フレームワークと合わせてください。
typeof window から useEffect への移行チェック
レガシーコードに残る typeof window !== 'undefined' 分岐は、2026 年時点ではアンチパターンとして明示的に置き換えるのが望ましいです。移行手順は次のとおりです。
grep -r "typeof window" src/で該当ファイルを列挙- 分岐で 異なるタグを返している箇所を優先修正(iframe / canvas / 地図 SDK)
- 同じラッパー要素 +
useEffectマウント後差し替え、またはdynamic(..., { ssr: false }) - テストで Playwright の console 監視を CI に追加
# 環境依存コードの一括洗い出し例(ripgrep)
rg "typeof window|localStorage|sessionStorage|matchMedia|navigator\." src/ --type ts --type tsx
'use client';
import { useEffect, useState } from 'react';
/** typeof window 分岐の安全な置き換えテンプレート */
export function SafeBrowserWidget() {
const [ready, setReady] = useState(false);
useEffect(() => {
setReady(true);
}, []);
return (
<div className="widget-shell min-h-[200px]">
{ready ? (
<iframe
title="埋め込みウィジェット"
src="https://widget.example.com/embed"
className="h-[200px] w-full border-0"
/>
) : (
<p className="text-sm text-zinc-500">ウィジェットを読み込み中</p>
)}
</div>
);
}
骨格(widget-shell)は SSR/CSR で同一であることが重要です。プレースホルダの高さを本番 UI と揃えれば、CLS も CRP 最適化 の観点から安全です。
関連記事
この記事は フロントエンド・CSS テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。
| トピック | 記事 |
|---|---|
| CSS Grid vs Flexbox | CSS Grid vs Flexbox|どっちを使う?使い分けと実戦レイアウト |
| CSS Container Queries 完全ガイド | CSS Container Queries 完全ガイド|@container・size vs inline-size・コンポーネント単位レスポンシブ |
| CSSですりガラス(Glassmorphism)を作る方法 | CSSですりガラス(Glassmorphism)を作る方法|backdrop-filterの使い方 |
| CSSで浮き出るカードを作る | CSSで浮き出るカードを作る|きれいなbox-shadowの数値設定 |
| CSSの単位はpxとremどちらが良い? | CSSの単位はpxとremどちらが良い?|基準16pxの理由と使い分け |
| Fluid Typographyの作り方 | Fluid Typographyの作り方|vwとclampでレスポンシブ対応を自動化 |
| line-height計算 - 読みやすい行間をフォントサイズから算出 | line-height計算 - 読みやすい行間をフォントサイズから算出 |
| Tailwind CSSでデザインシステムの色を管理する方法 | Tailwind CSSでデザインシステムの色を管理する方法 |
| 色のコントラストチェック | 色のコントラストチェック|color contrastの使い方と注意点 |
| HTML Popover API 完全ガイド | HTML Popover API 完全ガイド|popover・popovertarget・light dismiss・アクセシビリティ |
| Astro Content Collections でブログを構築する | Astro Content Collections でブログを構築する |
| CSP Report-Only 段階導入ガイド | CSP Report-Only 段階導入ガイド|本番を壊さずポリシーを検証する |
まとめ
Hydration mismatch(SSR ハイドレーション エラー)は、サーバー HTML とクライアント初回 React レンダーの不一致です。hydration mismatch 原因の上位は Date・乱数・ブラウザ API・invalid HTML・拡張機能の5類型に集約できます。
修正の優先順位:
- 環境依存値を render から追い出す —
useEffect+useState、または server 固定フォーマット - SSR 非対応 UI を分離 — Next.js
dynamic(..., { ssr: false })、Astroclient:only - 安定 id —
useId()、server 生成 UUID を props で - 意図的差異のみ —
<time suppressHydrationWarning> - HTML 構造の正当性 — 仕様違反ネストを修正
表示のちらつきと Core Web Vitals への影響は クリティカルレンダリングパス最適化 と合わせて計測し、サーバー HTML を信頼できる単一の真実として保つ — それが SSR を選んだ理由を最大化する道です。