React 19でハイドレーションエラーの表示はどう変わったか
React 19 では、ハイドレーションでサーバーの HTML とブラウザの描画が食い違うと、エラーが1件にまとまって出ます。そのエラーには、食い違った場所までの部品の並びと、サーバー側とブラウザ側の値が、差分の形で入ります。React 18 では、文字や要素の種類の食い違いで4〜5件のエラーがばらばらに出て、値が書かれていないものもありました。
筆者は検証環境で、わざと食い違いを作ったページを React 18 と 19 で描き、Chromium で開いて、出たエラーを記録して比べました。
検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 18.3.1 と 19.3.1、typescript 7.0.2、esbuild 0.28.2 です。.tsx を tsc で型チェックしてから esbuild で変換し、Chromium 141 を Playwright 1.56.1 で操作しました。Chromium は画面のないモード(ヘッドレス)で動かしています。
React 19 の開発用ビルドでは、食い違い1つにつきエラーは1件でした。メッセージの最後に、<App> から食い違った要素までの部品の並びがあり、+ の行がブラウザ側、- の行がサーバー側の値です。React 18 では、値がメッセージに出たのは文字と属性の食い違いだけで、要素の種類の食い違いでは <div> が見つからないとだけ出ました。
食い違いを3種類作って、React 18 と 19 で描いた
ハイドレーションは、サーバーで作った HTML に、ブラウザ側の React をつなぐ処理です(公式リファレンスの、サーバーで作った HTML をハイドレーションする方法)。このとき、ブラウザで最初に描いた結果がサーバーの HTML と違うと、エラーになります。
検証では、注文の確認画面を模した小さな部品を作りました。Status という部品の中で、typeof window を見てサーバーかブラウザかを判断し、わざと違うものを描きます。食い違いは、文字・属性(className)・要素の種類の3種類です。
const isServer = typeof window === 'undefined';
function Status({ scenario }: { scenario: Scenario }) {
if (scenario === 'text') {
// 文字が食い違う
return <p>描いた場所:{isServer ? 'サーバー' : 'ブラウザ'}</p>;
}
if (scenario === 'attr') {
// 属性(className)だけが食い違う
return <p className={isServer ? 'status-server' : 'status-browser'}>発送の準備中です</p>;
}
// (中略)
if (scenario === 'element') {
// 要素の種類が食い違う
return isServer ? <p>発送の準備中です</p> : <div>発送の準備中です</div>;
}
return <p>発送の準備中です</p>;
}
Status の外側には App と OrderSummary の2つの部品があります。App は <main> の中に見出しの <h1> と OrderSummary を置き、OrderSummary は <section> の中に商品名の <p> と Status を置いています。サーバー側は renderToString で HTML を作り、ブラウザ側は次のように hydrateRoot でつなぎます。エラーの出し方を既定のままにするため、オプションは渡していません。
hydrateRoot(document.getElementById('root')!, <App scenario={window.SCENARIO} />);
React 18 と 19 では、まったく同じファイルを使いました。ブラウザ用のコードは、開発用(NODE_ENV=development)と本番用(NODE_ENV=production)の2通りに変換しています。
記録したのは2種類です。1つは console.error に出たもの、もう1つは window の error イベントです(console.warn も記録しましたが、どのページでも0件でした)。React は、ハイドレーションの食い違いから立ち直ったときのエラーを、既定では reportError で報告します(hydrateRoot の onRecoverableError を渡さない場合。react-dom 18.3.1 と 19.3.1 のコードで確認)。MDN の reportError の説明によると、reportError は、捕まえられなかった例外と同じように、コンソールや error イベントにエラーを届けます。
開発用ビルドで出たエラーの数
1ページにつき食い違いは1つです。食い違いのないページでは、どちらの版も記録は0件でした。
| 食い違い | React 18 | React 19 | ハイドレーションのあとの DOM |
|---|---|---|---|
| 文字 | 4件 | 1件 | ブラウザ側の文字に置き換わった |
| 属性 | 1件 | 1件 | サーバー側の className のまま |
| 要素の種類 | 5件 | 1件 | ブラウザ側の <div> に置き換わった |
ハイドレーションのあとの DOM は、18 と 19 で同じでした。違ったのは、エラーの数と中身です。
文字の食い違いは、1件のエラーに差分が入る
React 18 では、次の4件が出ました。console.error の2件のあとに、error イベントが2件続きます(部品の並びの行は一部を省いています)。
[1] console.error:
Warning: Text content did not match. Server: "サーバー" Client: "ブラウザ"
at p
at Status (http://localhost:4173/client.js:24513:21)
at section
at OrderSummary (http://localhost:4173/client.js:24507:27)
(中略)
[2] console.error:
Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.
[3] window の error イベント:
Text content does not match server-rendered HTML.
[4] window の error イベント:
There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.
1件目に値と部品の並びがあり、2件目に「サーバーの HTML をブラウザの描画に置き換えた」とあります。何が食い違い、React がどう対処したかを知るには、1件目と2件目を合わせて読む必要があります。3件目と4件目は、1件目・2件目とほぼ同じことを、値を付けずに1文ずつ繰り返しています。
React 19 では、error イベントが1件だけでした。
Hydration failed because the server rendered text didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:
- A server/client branch `if (typeof window !== 'undefined')`.
- Variable input such as `Date.now()` or `Math.random()` which changes each time it's called.
- Date formatting in a user's locale which doesn't match the server.
- External changing data without sending a snapshot of it along with the HTML.
- Invalid HTML tag nesting.
It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
https://react.dev/link/hydration-mismatch
<App scenario="text">
<main>
<h1>
<OrderSummary scenario="text">
<section>
<p>
<Status scenario="text">
<p>
+ ブラウザ
- サーバー
分かりやすくなった点は3つあります。
1つ目は、何が起きて、どうなったかが、最初の2文で分かることです。サーバーの文字(text)がブラウザと合わなかったので、この部分をブラウザで作り直す、と書かれています。
2つ目は、よくある原因の一覧が付いていることです。typeof window での分岐、Date.now() や Math.random()、利用者の地域設定に合わせた日付の書式、HTML のタグの入れ子の誤り、ブラウザの拡張機能などが挙がっています。この検証の食い違いは、一覧の1つ目の typeof window での分岐にあたります。
3つ目は、最後の差分です。<App> から食い違った <p> までの部品の並びが、渡した props(scenario="text")つきで出ます。+ の行がブラウザで描いた値、- の行がサーバーの HTML にあった値です。React 18 の1件目の部品の並びは、親をたどる行だけでした。React 19 では、手前にある <h1> や <p> のような周りの要素と、部品に渡した props も入るので、画面のどこで食い違ったかを見つけやすくなっています。
React 19 のリリースのお知らせにある、ハイドレーションエラーの差分の説明でも、これまでは食い違いの中身のないエラーが何件も出ていたのを、差分つきの1件にした、と書かれています。
なお、メッセージの1文目の「text」の部分は、食い違いの種類で変わります。React の変更履歴によると、19.2.0 で、文字の食い違いのときは「HTML」ではなく「text」と出すようになりました。
要素の種類の食い違いは、18 では値が出なかった
<p> のはずが <div> になる食い違いでは、React 18 のエラーが5件になりました(部品の並びの行は省いています)。
[1] console.error:
Warning: Expected server HTML to contain a matching <div> in <section>.
(中略)
[2] window の error イベント:
Hydration failed because the initial UI does not match what was rendered on the server.
[3] console.error:
Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.
[4] window の error イベント:
Hydration failed because the initial UI does not match what was rendered on the server.
[5] window の error イベント:
There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.
1件目は、<section> の中に <div> があるはずなのにない、という書き方です。サーバー側に何があったか(<p>)は出ません。2件目と4件目は同じ文です。3件目の <div> は、食い違った <div> ではなく、ハイドレーションしたルートの <div id="root"> のことです。この3件目と同じ文は、文字の食い違いでも2件目に出ていました。
React 19 では1件で、最後の差分は次のとおりでした(1文目の text が HTML になるほかは、前半の説明と原因の一覧は文字の食い違いのときと同じです)。
<App scenario="element">
<main>
<h1>
<OrderSummary scenario="element">
<section>
<p>
<Status scenario="element">
+ <div>
- <p>
ブラウザでは <div>、サーバーでは <p> だったことが、それぞれ1行で分かります。
属性の食い違いは、19 でも直されずに残る
className だけが違う場合は、どちらの版も console.error が1件でした。React 18 は、次の1文と部品の並びでした(部品の並びは省いています)。
Warning: Prop `className` did not match. Server: "status-server" Client: "status-browser"
(部品の並びは略)
React 19 では、ほかの食い違いと同じ形の説明、原因の一覧、差分が出ました。1文目が違い、「属性が合わなかったが、直さない(This won't be patched up)」と書かれています。
A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up. This can happen if a SSR-ed Client Component used:
(原因の一覧とリンクは文字の食い違いのときと同じなので略)
<App scenario="attr">
<main>
<h1>
<OrderSummary scenario="attr">
<section>
<p>
<Status scenario="attr">
<p
+ className="status-browser"
- className="status-server"
>
+ 発送の準備中です
食い違ったのは className の行です。最後の + 発送の準備中です は <p> の中身で、サーバーの HTML にも同じ文字がありました。
メッセージのとおり、ハイドレーションのあとも DOM の class は status-server のままでした。React 18 でも同じです。属性の食い違いは、文字や要素の食い違いとは異なり、ブラウザ側で作り直されません。この検証では、error イベントに何も出ず console.error にだけ出たのは、属性の食い違いだけでした。
本番用ビルドでは、どちらも番号だけの短いエラーになる
本番用ビルドでは、どちらの版もエラーの中身は番号だけになり、差分は出ませんでした(19 は、番号に食い違いの種類が付くだけです)。React 18 は文字の食い違いで次の2件です(error イベント)。
Minified React error #425; visit https://reactjs.org/docs/error-decoder.html?invariant=425 for the full message or use the non-minified dev environment for full errors and additional helpful warnings.
Minified React error #423; visit https://reactjs.org/docs/error-decoder.html?invariant=423 for the full message or use the non-minified dev environment for full errors and additional helpful warnings.
React 19 は1件でした。
Minified React error #418; visit https://react.dev/errors/418?args[]=text&args[]= for the full message or use the non-minified dev environment for full errors and additional helpful warnings.
#425 は「Text content does not match server-rendered HTML.」、#423 はルート全体をブラウザで描き直したという意味です。要素の種類の食い違いでは、React 18 は #418 と #423 の2件、React 19 は args[]=HTML の付いた #418 の1件でした。19 の #418 は、開発用ビルドの長いメッセージと同じ文で、args[] の値が1文目の「text」や「HTML」の部分に入ります(React のエラーコードの一覧で確認)。同じ #418 でも、React 18.3.1 のエラーコードの一覧では文が違い、要素の種類の食い違いで開発用ビルドの2件目と4件目に出たのと同じ「Hydration failed because the initial UI does not match what was rendered on the server.」です。属性の食い違いは、どちらの版も本番用ビルドでは何も記録されませんでした。
差分を見るには、開発用ビルドで動かす必要があります。
差分を見たあとの直し方
この検証の食い違いは、typeof window で描くものを分けていたのが原因です。ブラウザでだけ違うものを出したいときは、公式リファレンスの、サーバーとブラウザで違う中身を描く方法のとおり、最初の描画はサーバーと同じにして、エフェクトで state を変えて描き直します。
公式リファレンスでは、この方法は部品を2回描くのでハイドレーションが遅くなること、遅い回線では切り替わりが目立つことも注意されています。日時のように、1つの要素の文字や属性がどうしても合わない場合には、suppressHydrationWarning でその要素のエラーを止める方法も紹介されています。この記事では suppressHydrationWarning は試していません。
検証で使った、エフェクトで描き直す部品は次のとおりです。
function WhereRendered() {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
return <p>描いた場所:{isClient ? 'ブラウザ' : 'サーバー'}</p>;
}
この部品に替えたページでは、React 18 と 19 のどちらも、開発用と本番用のどちらのビルドでも、記録は0件でした。ハイドレーションのあとの DOM は「描いた場所:ブラウザ」になっていました。





