React 19でハイドレーションエラーの表示はどう変わったか

​​React​Playwright,TypeScript,サーバーレンダリング

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種類です。

tsx
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 でつなぎます。エラーの出し方を既定のままにするため、オプションは渡していません。

tsx
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件続きます(部品の並びの行は一部を省いています)。

text
[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件だけでした。

text
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件になりました(部品の並びの行は省いています)。

text
[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 になるほかは、前半の説明と原因の一覧は文字の食い違いのときと同じです)。

text
  <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文と部品の並びでした(部品の並びは省いています)。

text
Warning: Prop `className` did not match. Server: "status-server" Client: "status-browser"
(部品の並びは略)

React 19 では、ほかの食い違いと同じ形の説明、原因の一覧、差分が出ました。1文目が違い、「属性が合わなかったが、直さない(This won't be patched up)」と書かれています。

text
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 イベント)。

text
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件でした。

text
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 は試していません。

検証で使った、エフェクトで描き直す部品は次のとおりです。

tsx
function WhereRendered() {
  const [isClient, setIsClient] = useState(false);
  useEffect(() => {
    setIsClient(true);
  }, []);
  return <p>描いた場所:{isClient ? 'ブラウザ' : 'サーバー'}</p>;
}

この部品に替えたページでは、React 18 と 19 のどちらも、開発用と本番用のどちらのビルドでも、記録は0件でした。ハイドレーションのあとの DOM は「描いた場所:ブラウザ」になっていました。

​Posted by 86