Hydration (ハイドレーション)
Hydration は、サーバー or ビルド時に生成された静的 HTML に、クライアント側 JavaScript が「動作するための配線」を後付けするプロセス。React の hydrateRoot、Vue の createSSRApp、Astro の Islands 等、SSR / SSG を採用した場合に必ず通る工程。
先に押さえる指標: FCP / LCP / TTI
この後のスイムレーン図には FCP / LCP / TTI のマイルストーンが登場する。それぞれが「ユーザー体験のどの瞬間」を測っているのかを先に整理しておくと、図の中で『なぜここが重要なのか』が腹落ちしやすい。
- FCP First Contentful Paint — 最初の視覚コンテンツ描画
- テキスト・画像・SVG・非空の Canvas のような「視覚的なコンテンツ」がブラウザ画面に最初に描画された時点。背景色のみの画面は含まれない。Core Web Vitals の補助指標で、1.8 秒以内が良好とされる。
- LCP Largest Contentful Paint — 最大コンテンツ描画
- ページ内で最大のコンテンツ要素 (ヒーロー画像・主要見出し等) が描画された時点。「意味のある初回描画」の代理指標で、Core Web Vitals の主指標。2.5 秒以内が良好、4 秒以上は要改善。
- TTI Time to Interactive — 操作可能になった時刻
- ページが完全にインタラクティブ (クリックや入力に反応できる) になった時点。全てのイベントリスナーが付与され、main thread が 5 秒間 Long Task を出さなくなるタイミング。2024 年に Core Web Vitals 主指標から外れ INP に置き換わったが、実ユーザーの「使えるようになる時刻」として依然重要。
ハイドレーションの概論
ハイドレーションは、サーバー or ビルド時に生成された静的 HTML に対して、クライアント側 JavaScript が「動作するための配線」を後付けするプロセス。SSR / SSG を採用した時点で必ず通る工程で、React / Vue / Svelte / Astro それぞれ独自の実装を持つが、概念は共通。web.dev の定義が最も端的:
「サーバーでレンダリングされた HTML に、アプリケーションの state とインタラクティビティを追加するためにクライアントサイドのスクリプトを実行すること」
— web.dev: Rendering on the Web (CC BY 4.0) より。英語原文をサイト独自に和訳。
React 公式はさらに具体的に、ハイドレーションが作り出す「体験」を説明している:
「ハイドレーションは、サーバーからの初期 HTML スナップショットを、ブラウザで動く完全にインタラクティブなアプリへと変える」
— React: hydrateRoot (CC BY 4.0) より。英語原文をサイト独自に和訳。
レンダリングライフサイクル
サーバーレンダリング完了 (01) からハイドレーション完了 (08) までの 9 フェーズ。03 で FCP / LCP に到達した後、JS が実行されて 08 で TTI に到達するまでの間、「見えているが操作できない」uncanny valley が発生するのがハイドレーションの本質的な代償。
-
01 renderToString で HTML + 埋め込み state を生成
サーバー側で React / Vue / Svelte などが renderToString() を実行。完成 HTML に加え、クライアントでの再構築に使う state を
<script>に埋め込んで返す。React 公式によれば「Server rendering creates an illusion that the app loads faster by showing the HTML snapshot of its output」。 -
02 HTML + state を転送
HTTP レスポンスで完成 HTML を返却。直後または並行して JS バンドルのダウンロードが始まる。
-
03 HTML parse → Layout → Paint
ブラウザが完成 HTML をパースして DOM → CSSOM → Layout → Paint を実行。この時点でユーザーはコンテンツを見ることができる。ただしクリックに反応しないのが後述のハイドレーション前の状態。
- FCP / LCP 到達 — コンテンツは見えているが操作不可
-
04 JS バンドル parse + compile
JS バンドルが到着するとエンジン (V8 等) がパース+コンパイル。ここからメインスレッドが本格的にブロックされ始める。web.dev: 「The primary downside of server-side rendering with rehydration is that it can have a significant negative impact on TBT and INP」。
-
05 hydrateRoot() 呼び出し / VDOM 再構築
React 公式の定義: 「hydrateRoot lets you display React components inside a browser DOM node whose HTML content was previously generated by react-dom/server」。コンポーネントツリーを実行して仮想 DOM をゼロから構築する。この時点ではまだ実 DOM には触らない。
-
06 既存 DOM とのマッチング (reconciliation)
仮想 DOM ツリーを、既に描画されている実 DOM と 1 ノードずつ比較。React 公式: 「React will attach to the HTML that exists inside the domNode, and take over managing the DOM inside it」。マークアップが一致していれば DOM を再生成せず、そのまま「引き取る」。
-
07 イベントリスナーの付与 + state 初期化
マッチした DOM ノードに対して、コンポーネント定義にあるイベントリスナー (onClick 等) を attach。useState / Vuex / pinia などの状態管理も初期化される。web.dev の定義「Running client-side scripts to add application state and interactivity to server-rendered HTML」がまさにこのフェーズ。
-
08 useEffect / onMounted 発火
ライフサイクル系のフックが発火して、API fetch / DOM 参照 / 外部ライブラリ初期化などの副作用処理が走る。SSR 中は実行されなかったブラウザ API (window / localStorage / IntersectionObserver 等) にここで初めてアクセスできる。
- TTI 到達 — ここから本当にページが使える
-
09 (不一致検出時) subtree の再レンダリング
サーバー HTML とクライアント VDOM が一致しない場合、React は警告を出してその subtree を捨てて再レンダリング。React 公式: 「React recovers from some hydration errors, but you must fix them like other bugs. In the best case, they'll lead to a slowdown; in the worst case, event handlers can get attached to the wrong elements」。
ハイドレーションの戦略
標準ハイドレーションの弱点 (TBT / INP 悪化) を緩和するために、いくつかの変種が提案・実装されている。それぞれの設計思想と実装例。
標準ハイドレーション
React hydrateRoot / Vue createSSRAppページ全体の HTML を一度にハイドレートする最も基本的な方式。サーバーで renderToString した HTML に対して、クライアントで同じコンポーネントツリーを hydrateRoot() (React) / createSSRApp(...).mount() (Vue) で再構築し、ツリー全体にイベントリスナーを付ける。JS バンドル全体のダウンロード+実行を待つため、TTI までの待ち時間が最も長くなる方式。
プログレッシブハイドレーション
web.dev が提唱する方式web.dev の定義: 「Individual pieces of a server-rendered application are 'booted up' over time, instead of the current common approach of initializing the entire application at once」。ページ全体を一括ではなく部分ごとに順次ハイドレートすることで、ビューポート外や低優先度の UI を後回しにして初期負荷を軽減する。
部分ハイドレーション / 選択的ハイドレーション (Islands)
Astro Islands ArchitectureAstro 公式の定義: 「render HTML pages on the server, and inject placeholders or slots around highly dynamic regions ... that can then be 'hydrated' on the client into small self-contained widgets」。ページの大部分は純粋な静的 HTML として配信し、インタラクティブな部分だけを「島」として JavaScript ランタイムを持ち込む。Astro は client:load / client:visible / client:idle 等のディレクティブで島をどう起動するかを明示的に指示する。
選択的ハイドレーション (Suspense ベース)
React 18+ (公式: Suspense)React 公式 (Suspense API リファレンス): 「React includes under-the-hood optimizations like Streaming Server Rendering and Selective Hydration that are integrated with Suspense」。すなわち、ストリーミング SSR と選択的ハイドレーションは
Resumability (ハイドレーション不要)
Qwik (公式: Resumable)Qwik 公式の定義: 「Resumability is about pausing execution on the server and resuming execution on the client without having to replay and download all of the application logic」。さらに通常のハイドレーションとの違いについて: 「All other frameworks' hydration replays all the application logic on the client. Qwik instead pauses execution on the server, and resumes execution on the client」。同じく Qwik 公式: 「Qwik collects component boundary information as part of the SSR/SSG and then serializes that information into HTML」。すなわち、クライアント側でアプリ全体を再生 (replay) せず、必要になった瞬間に該当箇所だけ「再開 (resume)」する設計。
よくある疑問
Q. なぜハイドレーションが必要なの? サーバーで HTML 作ったらそれで十分じゃない?
Q. 「hydration mismatch」って何が悪いの?
Date.now() / Math.random() をレンダリングに使う、(b) typeof window で分岐させる、(c) localStorage の値を初回レンダリングで使う、等。useEffect の中に移せば解決することが多い。Q. なぜハイドレーションで TBT / INP が悪化するの?
Q. Astro の Islands って普通のハイドレーションと何が違う?
client:load / client:visible 等を付けて「島」として hydrate 対象にする。標準ハイドレーション (React hydrateRoot) が「ページ全体を JavaScript で動かす」前提なのに対し、Islands は「ほぼ静的、必要な所だけ動的」という逆向きの設計思想。結果として JS 配信量が大幅に減る。Q. React Server Components と hydration の関係は?
設計判断のサイン
ページ全体がインタラクティブな SSR アプリ
ログイン後ダッシュボードのように「ページのほぼ全領域に state / イベントハンドラがある」ケースでは、Next.js / Nuxt / SvelteKit のデフォルトのハイドレーション戦略 (renderToString → hydrateRoot 等) で十分。React 公式が紹介する hydrateRoot は「the initial HTML snapshot from the server into a fully interactive app」を作るためのものであり、こうしたユースケースが本来の対象。追加の最適化は INP / TBT の実測値で問題が確認されてから検討する。
コンテンツ中心 / インタラクティブ領域が局所的
ブログ・ドキュメント・EC の商品ページのように、99% が読むだけで 1% (カート追加ボタン、検索ボックス等) だけ動く場合、Astro Islands や React RSC のような部分ハイドレーションが圧倒的に効く。Astro 公式曰く「stripping out all client-side JavaScript automatically」というデフォルトが活きる領域。
Core Web Vitals の TBT / INP で苦戦している
計測で TBT / INP が悪化していて、原因が「ハイドレーション完了まで時間がかかっている」ことが特定できた場合、プログレッシブハイドレーション / 選択的ハイドレーション (React 18+ の Suspense 境界) / Islands の導入を検討する価値がある。web.dev が指摘する通り「SSR with rehydration」の代表的な副作用。
純粋な CSR (SPA) 構成
CSR-only な SPA では、そもそも「サーバーが生成した HTML」が存在しないので、ハイドレーションという工程自体が発生しない。ブラウザは空の HTML を受け取って JavaScript が全てを描画する。ハイドレーションを考える必要があるのは SSR / SSG を組み合わせる瞬間から。
JS を一切使わない静的サイト
素の SSG で JavaScript を一切含まない純粋な静的 HTML を配信する構成 (docs サイト / ブログ / LP の一部) では、ハイドレーションは不要。Astro のデフォルト動作のように、完成 HTML のみで成立するならそれが最も速く、最も軽い。
よくある落とし穴
hydration mismatch を放置する
dev ビルドで警告が出ていても「動いているから大丈夫」と無視しがちだが、React 公式は明確に「React recovers from some hydration errors, but you must fix them like other bugs」と警告している。最悪イベントハンドラが別要素に付くため、見た目に影響しない不具合として潜伏する。根本原因は Date.now() / Math.random() / UA 分岐 / typeof window 判定等で、useEffect 内に移すのが基本対応。
ハイドレーション前にクリックが発生する競合
FCP / LCP は速いが TTI までに時間がかかる SSR アプリでは、ユーザーが「見えているボタン」をハイドレーション完了前にクリックして、クリックが無視される現象が起きる。web.dev 曰く「Server-side rendered pages can appear to be loaded and interactive, but can't actually respond to input until the client-side scripts for components are executed」。対策: React 公式 (Suspense リファレンス) が指摘する通り「React includes under-the-hood optimizations like Streaming Server Rendering and Selective Hydration that are integrated with Suspense」—
State の二重シリアライズでペイロードが肥大化
web.dev: 「Rehydration can cause more problems than just delayed interactivity」- サーバーレンダリングした HTML と、クライアント側で state を再構築するためのシリアライズ data (__NEXT_DATA__ 等) の 両方 が HTML に入るため、ペイロードが実質 2 倍になる。特に API レスポンスの全量をシリアライズする実装だと顕著。対策: Server Components / Islands で「クライアントで本当に必要な state だけ」に絞る、もしくは streaming SSR で事後送信する。
ブラウザ API を SSR レンダリング内で使ってクラッシュ
Node.js 上の SSR では window / document / localStorage / IntersectionObserver 等が存在しない。レンダリング関数内でこれらに触るとサーバー側で ReferenceError。対策は 3 つ: (a) typeof window !== 'undefined' でガード (ただしハイドレーション不一致の原因にもなるので注意)、(b) useEffect / onMounted に処理を移す (クライアント限定で実行)、(c) Next.js 公式 (Lazy Loading ガイド) の説明する通り「If you want to disable prerendering for a Client Component, you can use the ssr option set to false」— dynamic(() => import(...), { ssr: false }) でコンポーネント自体を SSR から外す。