React SSR Hydration Mismatch 原因と修正|Next.js・Astro対応ガイド

React SSR Next.js Astro フロントエンド
結論
  • Hydration mismatch(SSR ハイドレーション エラー)の大半は、サーバーとクライアントで異なる値を JSX に直接埋め込んでいることが原因だ。
  • DateMath.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・ハッシュ

非決定的な値を idkeyclassName に使うと、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:windowlocalStorage・メディアクエリ

ブラウザ専用 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

切り分け手順:

  1. シークレット / プライベートウィンドウ(拡張オフ)で再現するか
  2. 別ブラウザで再現するか
  3. 不一致ノードの 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参照)
  • useEffectPaint 後に走るため、一瞬 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:idlerequestIdleCallback 後
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 します。

診断ワークフロー

1

コンソールで Server/Client の不一致値をメモする

2

該当コンポーネントを React DevTools Components タブで特定する

3

grep で Date|random|localStorage|window|typeof window を検索する

4

シークレットウィンドウで拡張起因か切り分ける

5

useEffect / dynamic ssr:false / client:only のいずれかで修正する

6

修正後 Lighthouse で CLS・LCP を再計測する

再現を安定させる

  • next devnext 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 が起きにくく、動的穴は Suspenseclient:only で明示的に分離します。

方式静的シェル動的穴の典型mismatch リスク
Astro 島.astro テンプレートclient:only / client:visible島内の React コード次第
Next.js PPRプリレンダーされた layoutSuspense 内の dynamic importClient 子の環境依存コード
従来 SSRリクエストごとに全 HTMLページ全体が対象影響範囲が最大

2026 年の新規プロジェクトでは、LCP 要素は静的シェルに置き、時刻・乱数・localStorage 依存 UI は穴に追いやる設計が定石です。パフォーマンス計測は クリティカルレンダリングパス最適化 の FCP・LCP フレームワークと合わせてください。

typeof window から useEffect への移行チェック

レガシーコードに残る typeof window !== 'undefined' 分岐は、2026 年時点ではアンチパターンとして明示的に置き換えるのが望ましいです。移行手順は次のとおりです。

  1. grep -r "typeof window" src/ で該当ファイルを列挙
  2. 分岐で 異なるタグを返している箇所を優先修正(iframe / canvas / 地図 SDK)
  3. 同じラッパー要素 + useEffect マウント後差し替え、または dynamic(..., { ssr: false })
  4. テストで 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 FlexboxCSS 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 mismatchSSR ハイドレーション エラー)は、サーバー HTML とクライアント初回 React レンダーの不一致です。hydration mismatch 原因の上位は Date・乱数・ブラウザ API・invalid HTML・拡張機能の5類型に集約できます。

修正の優先順位:

  1. 環境依存値を render から追い出すuseEffect + useState、または server 固定フォーマット
  2. SSR 非対応 UI を分離 — Next.js dynamic(..., { ssr: false })、Astro client:only
  3. 安定 iduseId()、server 生成 UUID を props で
  4. 意図的差異のみ<time suppressHydrationWarning>
  5. HTML 構造の正当性 — 仕様違反ネストを修正

表示のちらつきと Core Web Vitals への影響は クリティカルレンダリングパス最適化 と合わせて計測し、サーバー HTML を信頼できる単一の真実として保つ — それが SSR を選んだ理由を最大化する道です。