React で作ったコンポーネントは、書いただけでは画面に出ません。どの DOM 要素の中に描画するかを指定して、React に「ここを管理してください」と伝える必要があります。その入り口になるのが react-dom/client の createRoot と hydrateRoot です。この記事では、React 18 で入れ替わったこの2つのマウント API について、基本の書き方から options の使い分け、ハイドレーション不一致が起きたときの原因と対処までを解説します。
目次
React 18 でマウント API が react-dom/client に移った
React 17 までは、アプリの起点に ReactDOM.render() を書くのが定番でした。React 18 ではこれが非推奨となり、開発モードで警告が出るようになっています。そして React 19 では ReactDOM.render() と ReactDOM.hydrate()、ReactDOM.unmountComponentAtNode() が完全に削除されました。React 19 以降で古い書き方のまま動かそうとしても、そもそも関数が存在しないためエラーになります。
| React 17 までの書き方 | React 18 以降の書き方 |
|---|---|
ReactDOM.render(<App />, container) | createRoot(container).render(<App />) |
ReactDOM.hydrate(<App />, container) | hydrateRoot(container, <App />) |
ReactDOM.unmountComponentAtNode(container) | root.unmount() |
単なる名前の変更ではありません。createRoot で作ったルートに切り替えると、React 18 で追加された Concurrent Features が有効になります。分かりやすいのが自動バッチングで、ReactDOM.render() の頃はイベントハンドラの外側(setTimeout や fetch の then の中など)で状態を複数回更新すると、その回数だけ再レンダーが走っていました。createRoot ではこうした場所での更新もまとめて1回の再レンダーに集約されます。useTransition や useDeferredValue、ストリーミング対応の Suspense といった機能も、新しいルートの上で動くことが前提です。
import 元も react-dom ではなく react-dom/client です。ここを間違えると関数が見つからないので、まず import 文を確認してください。
createRoot でアプリを DOM にマウントする
createRoot(domNode, options?) は、指定した DOM 要素に React のルートを作成して返します。第1引数の domNode がマウント先で、第2引数の options は省略できます。ルートを作っただけでは何も描画されず、返ってきたルートの render() を呼んで初めて画面に出ます。
まず、マウント先になる空の要素を HTML 側に用意します。Vite などのテンプレートで見かける <div id="root"></div> がこれにあたります。
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<title>React App</title>
</head>
<body>
<!-- React がこの中を管理する -->
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
次に、その要素を取得してルートを作り、コンポーネントを描画します。
import { createRoot } from 'react-dom/client';
import App from './App';
// マウント先の DOM 要素を取得する
const container = document.getElementById('root');
// 要素が見つからない場合は先に弾いておく
if (!container) {
throw new Error('#root が見つかりません');
}
// ルートを作成する(この時点ではまだ何も描画されない)
const root = createRoot(container);
// ルートに React コンポーネントを描画する
root.render(<App />);
TypeScript で書く場合、document.getElementById() の戻り値は HTMLElement | null なので、そのまま createRoot() に渡すと型エラーになります。上の例のように if で絞り込むか、要素の存在が確実なら document.getElementById('root')! と非 null アサーションを付けます。
ここで押さえておきたいのが、初回の render() でマウント先の中身が一度クリアされるという挙動です。<div id="root"> の中に元々 HTML が書かれていても、React が描画する内容で置き換えられます。サーバー側で生成した HTML を残したまま React を動かしたい場合は、この後で説明する hydrateRoot を使います。
root が持つ render と unmount
createRoot() が返すルートには、render() と unmount() という2つのメソッドがあります。どちらも戻り値はありません。
| メソッド | 説明 |
|---|---|
root.render(reactNode) | ルートに React ノードを描画する。2回目以降の呼び出しは、既存のツリーを更新する形になる |
root.unmount() | ルート内のツリーを破棄し、すべてのコンポーネントをアンマウントして DOM から取り除く |
unmount() が要るのは、React で作った部品を後から取り外すケースです。既存のページの一部だけを React で作っているときや、モーダル・ウィジェットのように動的に生成して片付けたいときに使います。マウント先の DOM ノードごとページから消す場合は、消す前に unmount() を呼んでおくと、内部のコンポーネントのクリーンアップ処理が正しく走ります。
import { createRoot } from 'react-dom/client';
import Widget from './Widget';
const container = document.getElementById('widget-area')!;
const root = createRoot(container);
// 初回の描画
root.render(<Widget title="お知らせ" />);
// 同じルートで render を呼び直すと差分更新になる
root.render(<Widget title="更新後のお知らせ" />);
// 不要になったら破棄する
root.unmount();
注意点として、一度 unmount() したルートに対して再び render() を呼ぶことはできません。もう一度表示したい場合は createRoot() からやり直します。また createRoot() はページ内で複数回呼べるので、既存サイトの複数箇所を React 化するといった使い方も可能です。ただし SPA として作る場合、アプリ全体で1回だけ呼ぶのが普通です。
options でエラーの受け取り方を指定する
createRoot() の第2引数には、ルート全体の設定をオブジェクトで渡せます。エラー監視サービスへの送信をここにまとめておくと、アプリのどこで起きたエラーも1か所で拾えます。
| オプション | 説明 |
|---|---|
onUncaughtError | エラーバウンダリで捕捉されなかったエラーが発生したときに呼ばれる |
onCaughtError | エラーバウンダリで捕捉されたエラーが発生したときに呼ばれる |
onRecoverableError | React が自動的に回復できたエラーのときに呼ばれる。ハイドレーションの不一致もここに届く |
identifierPrefix | useId が生成する ID に付ける接頭辞。1ページに複数のルートを置くときの ID 衝突を防ぐ |
コールバックはいずれも、エラーオブジェクトと、componentStack を持つ情報オブジェクトの2つを受け取ります。componentStack にはどのコンポーネントで起きたかが文字列で入っているので、ログに残しておくと調査が楽になります。
import { createRoot } from 'react-dom/client';
import App from './App';
const root = createRoot(document.getElementById('root')!, {
// エラーバウンダリで捕まらなかった致命的なエラー
onUncaughtError: (error, errorInfo) => {
console.error('Uncaught:', error, errorInfo.componentStack);
},
// エラーバウンダリが受け止めたエラー
onCaughtError: (error, errorInfo) => {
console.warn('Caught:', error, errorInfo.componentStack);
},
// React が自力で回復したエラー
onRecoverableError: (error, errorInfo) => {
console.info('Recoverable:', error, errorInfo.componentStack);
},
// useId が生成する ID の接頭辞
identifierPrefix: 'admin-',
});
root.render(<App />);
hydrateRoot でサーバー生成の HTML に React を結び付ける
サーバーサイドレンダリング(SSR)を使うと、ブラウザに届く時点で HTML はすでに組み上がっています。この状態から createRoot() を呼ぶと、せっかくのサーバー製 HTML が消えて一から描き直されてしまいます。そこで使うのが hydrateRoot(domNode, reactNode, options?) です。
ハイドレーション(hydration)は、出来上がっている HTML をそのまま活かしつつ、そこに React のイベントハンドラや状態を「後から結び付ける」処理です。日本語にすると「水を与える」で、乾いた HTML に React という中身を注いで動くようにする、というイメージです。
import { hydrateRoot } from 'react-dom/client';
import App from './App';
// サーバー側でレンダーしたのと同じコンポーネントを渡す
hydrateRoot(document.getElementById('root')!, <App />);
createRoot() との一番大きな書き方の違いは、コンポーネントを第2引数で渡し、初回に render() を呼ばない点です。hydrateRoot() の呼び出し自体が初回の描画(正確にはサーバー製 HTML との結び付け)を兼ねているためです。
hydrateRoot() も render() と unmount() を持つルートを返すので、後から表示を差し替えたいときには render() が使えます。この2回目以降の render() は通常の更新として扱われ、再びハイドレーションが走るわけではありません。
const root = hydrateRoot(document.getElementById('root')!, <App theme="light" />);
// 初回は render を呼ばない。後から中身を差し替えたいときだけ呼ぶ
root.render(<App theme="dark" />);
options に渡せるものは createRoot() と同じで、onUncaughtError、onCaughtError、onRecoverableError、identifierPrefix が使えます。SSR の場合は特に onRecoverableError が重要で、後述するハイドレーションの不一致がここに通知されます。
なお、SSR を使っていないクライアント専用のアプリで hydrateRoot() を使うことはできません。マウント先が空だと結び付ける相手がなく、不一致だらけになります。SSR なら hydrateRoot、それ以外は createRoot、と覚えておけば十分です。
ハイドレーションの不一致が出るときに疑うところ
ハイドレーションは、サーバーが出力した HTML とクライアントでの初回レンダー結果が同一であることを前提にしています。ここがずれると開発モードで警告やエラーが表示され、React はその部分をクライアント側でレンダーし直します。コンソールに「Hydration failed because the server rendered HTML didn’t match the client」といったメッセージが出たら、以下のどれかに当てはまっていないか確認してください。
実行するたびに変わる値を描画している
最も多い原因です。new Date() や Date.now()、Math.random() をレンダー中に呼ぶと、サーバーで実行した時点の値とブラウザで実行した時点の値が当然違うため、必ず不一致になります。ユーザーのタイムゾーンやロケールに依存する toLocaleString() 系も同じで、サーバーとブラウザで表示結果が変わります。
対処は2通りあります。ずれても構わない小さな箇所なら、その要素に suppressHydrationWarning={true} を付けて警告を抑制します。ただしこれは1階層分にしか効かない上に、テキストの不一致を修正してくれるわけではないので、あくまで例外的な逃げ道として使います。
export function CurrentTime() {
// ずれることを承知の上で警告だけ止める
return <p suppressHydrationWarning={true}>{new Date().toLocaleTimeString()}</p>;
}
もう一つは、初回レンダーではサーバーと同じ内容を返しておき、マウントが終わってからクライアント用の内容に切り替える方法です。useEffect はブラウザでしか実行されないという性質を利用します。
import { useEffect, useState } from 'react';
export function CurrentTime() {
const [mounted, setMounted] = useState(false);
// useEffect はサーバーでは実行されない
useEffect(() => {
setMounted(true);
}, []);
// 初回レンダーはサーバーと同じ出力にしておく
if (!mounted) {
return <p>読み込み中</p>;
}
return <p>{new Date().toLocaleTimeString()}</p>;
}
ブラウザ専用の API で分岐している
window や document、localStorage はサーバーには存在しません。そのため typeof window !== 'undefined' のような判定をレンダー中に書くと、サーバーとクライアントで通る分岐が変わり、そのまま不一致になります。画面幅で表示を出し分ける、保存済みのテーマ設定を読んで色を変える、といった処理がこのパターンに当てはまります。
こうした値は、レンダー中ではなく useEffect の中で読み取り、状態に入れてから使うようにします。前項の mounted を使う書き方がそのまま流用できます。
HTML の入れ子が不正でブラウザに直されている
意外と見落とすのがこれです。<p> の中に <div> を入れる、<table> の直下に <tbody> を挟まず <tr> を置く、といった不正な入れ子を書くと、ブラウザが HTML を解析する段階で構造を勝手に補正します。React が出力したはずの HTML と、ブラウザが実際に組み立てた DOM が食い違うため、不一致として検出されます。
この場合は警告を抑制しても解決しません。マークアップ自体を正しい入れ子に直してください。
ブラウザ拡張機能が HTML を書き換えている
コードに問題がないのに不一致が出る場合、翻訳系やパスワード管理系の拡張機能が、React のハイドレーション前に DOM へ属性や要素を差し込んでいることがあります。シークレットウィンドウや拡張機能を無効にした状態で再現するか確かめると切り分けができます。自分のコードが原因でないと分かれば、そのまま進めて問題ありません。
StrictMode と組み合わせて書く
実際のプロジェクトでは、ルートに渡すコンポーネントを <StrictMode> で包む形をよく見かけます。StrictMode は開発時のみ働く仕組みで、コンポーネントを意図的に2回レンダーしたり、useEffect のセットアップとクリーンアップを余分に実行したりして、副作用の書き方の問題を早めに知らせてくれます。本番ビルドでは何もしないので、付けておいて損はありません。
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
hydrateRoot() の場合は第2引数を包みます。書く位置が違うだけで、考え方は同じです。
import { StrictMode } from 'react';
import { hydrateRoot } from 'react-dom/client';
import App from './App';
hydrateRoot(
document.getElementById('root')!,
<StrictMode>
<App />
</StrictMode>,
);
Next.js のようなフレームワークでは自分で呼ばない
ここまで見てきた createRoot と hydrateRoot は、React を素の状態から組み立てるとき、あるいは Vite などで自分でエントリーポイントを書くときに必要になる API です。Next.js や Remix のようなフレームワークを使っている場合、マウント処理はフレームワーク側が内部で済ませているため、自分のコードに createRoot() を書くことはありません。
Next.js でいえば、開発者が書くのはページやレイアウトのコンポーネントであって、それをどの DOM 要素に結び付けるかはフレームワークの担当です。SSR したページのハイドレーションも同様に自動で行われます。それでもこの API を知っておく価値があるのは、ハイドレーション不一致の警告が出たときに「今どういう処理が走っていて、なぜ不一致が問題になるのか」を理解していないと原因を追えないからです。
まとめ
React 18 以降のマウント API は react-dom/client にあり、クライアントだけで描画するなら createRoot(domNode, options?)、サーバーで生成済みの HTML に結び付けるなら hydrateRoot(domNode, reactNode, options?) を使います。createRoot() はルートを返すだけなので render() を呼んで描画し、片付けるときは unmount() を呼びます。hydrateRoot() は第2引数でコンポーネントを渡すため初回の render() は不要で、更新したいときだけ render() を使います。旧来の ReactDOM.render() は React 18 で非推奨、React 19 で削除されており、新しいルートに切り替えることで自動バッチングをはじめとする Concurrent Features が有効になります。options では onUncaughtError・onCaughtError・onRecoverableError でエラーを一括して受け取り、identifierPrefix で useId の ID 衝突を避けられます。ハイドレーションの不一致に出会ったら、時刻や乱数、ブラウザ専用 API による分岐、不正な HTML の入れ子、拡張機能の干渉という順に疑ってみてください。