サーバーがすでに HTML を作ったのに、なぜブラウザはもう一度同じコンポーネントを実行するのか?
Next.js でページを作ると、View Source で最初から中身の詰まった HTML が見える。なのに DevTools のコンソールを見ると、同じコンポーネントの関数がブラウザ側でも確かに呼ばれている。サーバーが一度描いた絵を、ブラウザがもう一度同じ手順で描き直しているように見えるが、これは無駄な二度手間なのだろうか。
- SSR はコンポーネントを実行して 文字列としての HTML を作るだけで、その HTML には「クリックされたら何をするか」という関数の中身は含まれない。
- ハイドレーションはブラウザで同じコンポーネントをもう一度実行し、その結果と届いた HTML を照合しながらイベントハンドラを DOM に取り付ける作業であり、DOM を作り直しているわけではない。
- サーバーとクライアントの出力が食い違う「ハイドレーションミスマッチ」が起きると、React はそこから下を丸ごと作り直すため、サーバー描画の恩恵が部分的に消える。
HTML は「静止画」でしかない
ブラウザが受け取る HTML そのものは、ボタンやテキストがどこにどう並ぶかという構造の情報でしかない。onClick に何を渡していたかという JavaScript の関数は、HTML の文字列にシリアライズできないため、そこには残らない。SSR でサーバーが React コンポーネントを実行して renderToString のような API で HTML を生成しても、できあがるのは見た目が完成した静止画であり、クリックしても何も起きないただのマークアップだ。
だからこそ、ブラウザ側でも一度コンポーネントを実行する必要がある。ここで実行するのはサーバーと同じコンポーネント関数であり、目的は新しい DOM を組み立てることではなく、「この <button> にはこのクリックハンドラが対応するはずだ」という対応関係を React 自身の頭の中に再構築することにある。
この再実行がなければ、届いた HTML はいつまで経ってもただの見た目のままだ。useState の初期値も、useEffect の中身も、クリックへの応答も、すべてブラウザで JavaScript が動いて初めて意味を持つ。SSR が速く感じられるのは表示までの時間であって、操作可能になるまでの時間はまた別に測る必要がある。
ハイドレーションは DOM を壊さない
React の hydrateRoot は、createRoot のようにまっさらな DOM ノードへ描画するのではなく、「この domNode の中身はサーバーが生成した HTML のはずだ」という前提で処理を進める。公式ドキュメントは、React がツリーを実行した結果はサーバー側の出力と一致するはずだという前提に立っており、一致していれば既存の DOM ノードをそのまま再利用してイベントハンドラだけを取り付けると説明している。
サーバー │ コンポーネントを実行 ▼ HTML 文字列(構造のみ、配線なし) │ ネットワークで送信 ▼ ブラウザ │ 同じコンポーネントを実行 ▼ 出力を既存 DOM と照合 │ 一致していれば ▼ イベントハンドラだけ装着 (新規 DOM 生成は無し)
つまり「もう一度描き直している」ように見えて、実際に新規作成されるノードは(一致している限り)ゼロだ。二度描画しているのではなく、一度描いた絵の上に配線を通していると考えるほうが近い。
SSR は完成品の写真だけを先に送る通販に近い。届いた箱を開けて「見た目はこれで合っている」と確認しながら、電源コードやボタンの配線を後から接続していく作業がハイドレーションだ。写真と実物が食い違っていたら、配線のしようがないのでその部分は箱ごと作り直すしかない。
サーバーとクライアントの出力が食い違うとき
React はサーバーの出力とクライアントの出力が完全に一致することを前提にしており、公式ドキュメントは不一致を「バグとして扱い修正すべきもの」だと明言している。よくある原因は、typeof window !== 'undefined' のような分岐でサーバーとクライアントの描画結果を意図的に変えてしまうこと、window.matchMedia のようなブラウザ専用 API をレンダリング中に呼んでしまうこと、現在時刻やランダム値のように呼ぶたびに変わる値を描画に使うこと、そしてルート直下の空白や改行のような一見無害な差分だ。
不一致が起きると React はどう振る舞うか。開発時にはコンソールに差分つきの警告を出したうえで、ミスマッチが見つかった直近の Suspense 境界(コンポーネントツリーを区切る単位)より下(境界がなければルート全体)のサーバー製 HTML を破棄し、クライアント側の描画結果で作り直す。属性の細かな食い違いが自動で「修復」される保証はなく、最悪の場合イベントハンドラが意図と違う要素に付いてしまう。ここで初めて「サーバーの仕事が無駄になる」状態が実際に発生する。
// 危険: サーバーとクライアントで出力が変わる
function Banner() {
const isClient = typeof window !== 'undefined';
return <p>{isClient ? 'ブラウザ版' : 'サーバー版'}</p>;
}
// 現在時刻のように避けられない差分は
// suppressHydrationWarning で「ここは検証しない」と伝える
<span suppressHydrationWarning>
{new Date().toLocaleTimeString()}
</span>
suppressHydrationWarning はあくまで警告を黙らせる逃げ道であり、React がその要素のテキストを自動で合わせ込んでくれるわけではない点に注意がいる。
ページ全体を一括で固めなくてもいい
ハイドレーションはページ全体を1回の処理で終わらせる必要はない。Next.js の App Router や React の Suspense を使うと、境界ごとに区切られた範囲は届いたタイミングで個別にハイドレーションできる。React はどの範囲がユーザーの操作対象かを見て、そこを優先的にハイドレーションするため、ページの一部がまだ準備中でも、ユーザーがクリックした部分から先に反応できるようになる。これを段階的なハイドレーションと呼ぶ。
これが効くのは、ページ全体を1回のブロッキング処理でハイドレーションする素朴な方式だと、重いコンポーネントが1つあるだけでメインスレッドが長時間ふさがり、他の部分のクリックにも反応できなくなるからだ。境界を分けて処理を細切れにすれば、ブラウザは合間に他の作業を挟めるようになる。
言い換えると、ハイドレーションは「全部終わるまで何もできない一発勝負」ではなく、境界の粒度に応じて途中経過を持てる処理だということになる。境界を置く場所の設計は、SSR の恩恵をどこまで維持できるかを左右する。
Next.js で開発中にコンソールへ赤字で "Hydration failed because the server rendered HTML didn't match the client" という警告を見た経験があるはずだ。日付表示・ユーザーのタイムゾーン・localStorage を読んでの分岐・ブラウザ拡張機能が挿入する属性など、原因の多くは「サーバーは知らないがクライアントだけが知っている情報」を描画に使ってしまうことにある。エラーメッセージが指す行から、サーバーとクライアントで結果が変わり得る値を洗い出すのが定石の直し方だ。
最小限の Next.js アプリで、意図的にハイドレーションミスマッチを起こして DevTools 上の警告を観察する。
npx create-next-app@latest hydration-demo --ts --app \
--no-tailwind --no-eslint --src-dir=false --import-alias="@/*"
cd hydration-demo
cat > app/page.tsx <<'EOF'
export default function Home() {
const isBrowser = typeof window !== 'undefined';
return <main><p>{isBrowser ? 'client' : 'server'}</p></main>;
}
EOF
npm run dev
http://localhost:3000 をブラウザで開き、DevTools の Console タブを見る。"Text content did not match" というハイドレーションエラーが出るはずだ。View Source(または curl localhost:3000)で確認すると HTML 内には server という文字列が入っているのに、画面には client と表示される。これは React がミスマッチを検知してクライアント側の結果で表示を上書きした結果であり、サーバーが最初に送った HTML が無視された瞬間を表している。
- ハイドレーションはブラウザが DOM をゼロから作り直す処理だ — サーバーとクライアントの出力が一致している限り、既存の DOM ノードは再利用され、イベントハンドラが取り付けられるだけで新規のノード生成は起きない。
- SSR すればページはブラウザで JavaScript を実行しなくても操作可能になる — SSR が届けるのは見た目の HTML だけで、クリックやフォーム入力に反応させるにはハイドレーションで JavaScript を実行してイベントハンドラを取り付ける必要がある。
- ハイドレーションのミスマッチは大抵の場合 React が自動で吸収してくれる — 属性の食い違いが自動で修復される保証はなく、React 公式もミスマッチは修正すべきバグとして扱うよう明言している。
- SSR(Server-Side Rendering)
- サーバー上でコンポーネントを実行し、完成済みの HTML を生成してからブラウザへ送る手法。
- ハイドレーション(hydration)
- ブラウザで同じコンポーネントを実行し、サーバー製 HTML の DOM ノードにイベントハンドラを取り付けてインタラクティブにする処理。
- hydrateRoot
- サーバーが生成した既存の HTML に React を接続するための React DOM の API。
- ハイドレーションミスマッチ
- サーバーとクライアントでコンポーネントの出力が食い違うこと。発生箇所より下の HTML が破棄され作り直される。
- Suspense 境界
- コンポーネントツリーを区切り、区切られた範囲ごとに独立してストリーミング・ハイドレーションできるようにする React の仕組み。
- 段階的ハイドレーション
- ページ全体を一括処理せず、Suspense 境界ごとに、ユーザーの操作対象を優先しながら順次ハイドレーションする方式。
- hydrateRoot – React — ハイドレーションの前提条件・不一致時の挙動・
suppressHydrationWarningの使い方を公式が解説している。 - Text content does not match server-rendered HTML | Next.js — 実際に遭遇するハイドレーションエラーの典型的な原因と直し方を Next.js 公式がまとめている。
- Streaming | Next.js — Suspense 境界ごとのストリーミングと段階的ハイドレーションの仕組みを具体例つきで解説している。