レンダリング

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 が発生するのがハイドレーションの本質的な代償。

Server
Network
Browser
JS Engine
Display
  1. 01

    renderToString で HTML + 埋め込み state を生成

    01 Server 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」。

  2. 02

    HTML + state を転送

    02 Network HTML + state を転送

    HTTP レスポンスで完成 HTML を返却。直後または並行して JS バンドルのダウンロードが始まる。

  3. 03

    HTML parse → Layout → Paint

    03 Browser HTML parse → Layout → Paint

    ブラウザが完成 HTML をパースして DOM → CSSOM → Layout → Paint を実行。この時点でユーザーはコンテンツを見ることができる。ただしクリックに反応しないのが後述のハイドレーション前の状態。

  4. FCP / LCP 到達 — コンテンツは見えているが操作不可
  5. 04

    JS バンドル parse + compile

    04 JS Engine 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」。

  6. 05

    hydrateRoot() 呼び出し / VDOM 再構築

    05 JS Engine 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 には触らない。

  7. 06

    既存 DOM とのマッチング (reconciliation)

    06 JS Engine 既存 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 を再生成せず、そのまま「引き取る」。

  8. 07

    イベントリスナーの付与 + state 初期化

    07 JS Engine イベントリスナーの付与 + state 初期化

    マッチした DOM ノードに対して、コンポーネント定義にあるイベントリスナー (onClick 等) を attach。useState / Vuex / pinia などの状態管理も初期化される。web.dev の定義「Running client-side scripts to add application state and interactivity to server-rendered HTML」がまさにこのフェーズ。

  9. 08

    useEffect / onMounted 発火

    08 JS Engine useEffect / onMounted 発火

    ライフサイクル系のフックが発火して、API fetch / DOM 参照 / 外部ライブラリ初期化などの副作用処理が走る。SSR 中は実行されなかったブラウザ API (window / localStorage / IntersectionObserver 等) にここで初めてアクセスできる。

  10. TTI 到達 — ここから本当にページが使える
  11. 09

    (不一致検出時) subtree の再レンダリング

    09 JS Engine (不一致検出時) 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 Architecture

Astro 公式の定義: 「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 と選択的ハイドレーションは 境界に紐づく形で動作する。境界ごとに段階的にハイドレートされる、Astro Islands のように「開発者が明示指定」ではなく Suspense 境界の設計から自然に部分化される、という構造的違いがある。

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 作ったらそれで十分じゃない?
HTML だけでは click / input に反応できない から。サーバーが生成するのは「見た目の静止画」であって、イベントハンドラーや state 管理は含まれていない。React 公式が整理している通り「The user will spend some time looking at the server-generated HTML before your JavaScript code loads」— 見えているが操作できない時間 が必然的に発生する。クライアントで JavaScript を走らせて「静止画に動作を後付けする」のがハイドレーションの役目。完全に静的な HTML で良いなら (SSG + 純粋静的サイト) ハイドレーション不要、動的 UI が欲しい瞬間に必要になる。
Q. 「hydration mismatch」って何が悪いの?
サーバーで出力した HTML と、クライアントで再レンダリングした結果が食い違っている状態。React 公式は警告の深刻度をこう整理している: 「In the best case, they'll lead to a slowdown; in the worst case, event handlers can get attached to the wrong elements」。最悪ケースではボタンに別の要素のハンドラが付いてしまう。対策の基本は「サーバーとクライアントで同じ出力になるようにする」で、よくある原因は (a) Date.now() / Math.random() をレンダリングに使う、(b) typeof window で分岐させる、(c) localStorage の値を初回レンダリングで使う、等。useEffect の中に移せば解決することが多い。
Q. なぜハイドレーションで TBT / INP が悪化するの?
web.dev が端的に指摘している: 「The primary downside of server-side rendering with rehydration is that it can have a significant negative impact on TBT and INP」。理由は (a) JS バンドルのパース+実行でメインスレッドが長時間ブロックされる、(b) コンポーネントツリーをゼロから再構築する計算コストが大きい、(c) ハイドレーション完了まで操作が効かない時間がある、(d) web.dev 曰く「server-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」。「見えているのにクリックが効かない」という「uncanny valley」状態が長いほど指標が悪化する。対策は後述のプログレッシブ / 部分ハイドレーション。
Q. Astro の Islands って普通のハイドレーションと何が違う?
Astro の立場は「デフォルトで JavaScript をゼロにする」。Astro 公式: 「By default, Astro will automatically render every UI component to just HTML & CSS, stripping out all client-side JavaScript automatically」。その上で、インタラクティブにしたい部分だけ開発者が client:load / client:visible 等を付けて「島」として hydrate 対象にする。標準ハイドレーション (React hydrateRoot) が「ページ全体を JavaScript で動かす」前提なのに対し、Islands は「ほぼ静的、必要な所だけ動的」という逆向きの設計思想。結果として JS 配信量が大幅に減る。
Q. React Server Components と hydration の関係は?
React 公式 (Server Components) は明確に整理している: 「Server Components are a new type of Component that renders ahead of time, before bundling, in an environment separate from your client app or SSR server」。そして決定的な性質として「Server Components are not sent to the browser, so they cannot use interactive APIs like useState」。さらに「The bundle does not include the expensive libraries needed to render the static content」。つまり Server Component はクライアントには JavaScript として送られず、出力された HTML / シリアライズ結果だけが届く。結果としてハイドレーション対象は Client Component のみに絞られ、JS バンドルと再構築する VDOM のサイズが小さくなる。Astro Islands と思想的に近いが、RSC は React の統合 API として提供されている。

設計判断のサイン

ハイドレーション採用が適切

ページ全体がインタラクティブな 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」— 境界で領域を区切り、選択的ハイドレーションを活用する。あるいは「Hydration 完了まで無効状態の UI」を作って視覚的に区別する。

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 から外す。