React のコンポーネントは通常ブラウザで DOM に描画しますが、サーバー側で「HTML の文字列」として書き出すこともできます。それを担うのが react-dom/server の renderToString() と renderToStaticMarkup() です。この記事では、この2つの API の書き方と使い分け、クライアントでハイドレーションするときの対応コード、そして React 18 以降ではストリーミング系の API が推奨されている理由までを解説します。
目次
react-dom/server で React を HTML 文字列にする
サーバーサイドレンダリング(SSR)は、ブラウザに空の <div id="root"> を送る代わりに、あらかじめ組み立てた HTML を返す手法です。最初の表示が速くなり、JavaScript を実行しないクローラーにも内容が伝わります。この「あらかじめ組み立てる」部分で使うのが react-dom/server の API で、React の要素ツリーを受け取って HTML の文字列やストリームを返します。
その中でもっとも素朴なのが renderToString() です。名前のとおり React ノードを文字列に変換して返すだけで、非同期処理を挟む余地がありません。もう一方の renderToStaticMarkup() は、React を後から動かす前提のない、完全に静的な HTML を作るための関数です。どちらも import 元は react-dom/server で、クライアント側の react-dom/client とは別のエントリーポイントになります。
| API | 用途 |
|---|---|
renderToString(reactNode, options?) | React ツリーを HTML 文字列にする。出力は hydrateRoot() でハイドレートできる |
renderToStaticMarkup(reactNode, options?) | インタラクティブでない HTML 文字列にする。ハイドレーションはできない |
第2引数の options はどちらも省略でき、指定できるのは identifierPrefix です。これは useId が生成する ID に付ける接頭辞で、1つのページに複数の React ルートを置く場合に、クライアント側の hydrateRoot() に渡す identifierPrefix と同じ値をサーバーでも指定して ID の衝突を防ぎます。
renderToString の基本の使い方
まずは Node.js のサーバーで HTML を返す最小の例を見てみます。ここでは Express を使っていますが、フレームワークは何でも構いません。ポイントは、renderToString(<App />) の戻り値がただの文字列なので、それをテンプレートの好きな場所に埋め込めるという点です。
import express from 'express';
import { renderToString } from 'react-dom/server';
import App from './App';
const app = express();
// ビルド済みの JS を配信する
app.use('/static', express.static('./dist'));
app.get('/', (req, res) => {
// React ツリーを HTML 文字列に変換する(同期的に完了する)
const appHtml = renderToString(<App />);
res.setHeader('Content-Type', 'text/html');
res.send(`<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<title>SSR のサンプル</title>
</head>
<body>
<div id="root">${appHtml}</div>
<script src="/static/client.js"></script>
</body>
</html>`);
});
app.listen(3000);
この時点でブラウザには文字も見出しも入った HTML が届きますが、ボタンを押しても何も起きません。renderToString() はマークアップだけを出力し、イベントハンドラーは HTML に含まれないからです。動くようにするには、ブラウザ側で同じコンポーネントを hydrateRoot() に渡して、既存の HTML に React を結び付けます。
import { hydrateRoot } from 'react-dom/client';
import App from './App';
// サーバーで renderToString に渡したものと同じコンポーネントを渡す
hydrateRoot(document.getElementById('root')!, <App />);
サーバーとクライアントで同じコンポーネントを同じ props で描画するのが原則です。ここが食い違うとハイドレーションの不一致になり、React はその部分をクライアント側で描き直します。なお、renderToString() はブラウザでも動作しますが、クライアントのコードで使うことは推奨されていません。画面に出す目的ならクライアントでは createRoot() を使います。
renderToStaticMarkup との違い
2つの関数は呼び出し方がまったく同じで、返ってくる HTML の中身が違います。renderToStaticMarkup() はReact が内部で使う余分な DOM 属性を出力しません。その代わりに、出力された HTML をハイドレートすることはできません。ハイドレーションに必要な目印が入っていないため、あとから hydrateRoot() で React を結び付けようとしても意図どおりに動きません。
| 比較項目 | renderToString | renderToStaticMarkup |
|---|---|---|
| ハイドレーション | hydrateRoot() と対で使える | できない |
| 出力される HTML | React が内部で使う情報を含む | 余分な属性を含まない素の HTML |
| 想定する用途 | あとから React を動かす SSR ページ | メール本文、静的な HTML ファイルなど |
言い換えると、そのページで React を動かすなら renderToString、HTML を出力して終わりなら renderToStaticMarkup という選び方になります。判断に迷うときは「クライアントで hydrateRoot() を呼ぶかどうか」を基準にすれば間違いません。
renderToStaticMarkup でメール本文の HTML を作る
renderToStaticMarkup() がよく使われるのがメールの HTML 生成です。メールクライアントでは JavaScript が動かないので React を動かす余地はなく、必要なのは完成した HTML だけです。文字列連結でメールのテンプレートを書くと可読性が下がりがちですが、JSX で書けばコンポーネントに分割でき、props で値を差し込めます。
type Props = {
name: string;
loginUrl: string;
};
// メール本文をコンポーネントとして組み立てる
export function WelcomeMail({ name, loginUrl }: Props) {
return (
<div style={{ fontFamily: 'sans-serif', lineHeight: 1.7 }}>
<h1>{name} さん、ご登録ありがとうございます</h1>
<p>下のリンクからログインしてください。</p>
<p>
<a href={loginUrl}>ログインする</a>
</p>
</div>
);
}
これを renderToStaticMarkup() に渡すと HTML 文字列が得られるので、そのままメール送信ライブラリの本文に渡せます。<!doctype html> は関数側では付かないので、必要なら自分で先頭に足します。
import { renderToStaticMarkup } from 'react-dom/server';
import { WelcomeMail } from './WelcomeMail';
export function buildWelcomeMailBody(name: string, loginUrl: string) {
const body = renderToStaticMarkup(
<WelcomeMail name={name} loginUrl={loginUrl} />,
);
// doctype が必要なら自分で付ける
return `<!doctype html><html lang="ja"><body>${body}</body></html>`;
}
JSX 内の文字列は React が自動でエスケープするため、ユーザー名などをそのまま埋め込んでも HTML が壊れません。同じ発想で、静的サイトジェネレーターのように HTML ファイルを書き出す処理にも使えます。
サーバーでは動かない処理がある
サーバーでのレンダリングは、コンポーネント関数を実行して HTML を組み立てるところまでで終わります。ブラウザで動くときの流れとは別物なので、いくつか動かない処理があります。
useEffect や useLayoutEffect は実行されない
これらのフックはマウント後に呼ばれるもので、サーバーでは呼ばれません。したがって、useEffect の中で setState して表示を切り替えるコンポーネントは、サーバー側では切り替え前の状態でレンダリングされます。データ取得を useEffect に書いている場合、その結果はサーバー出力の HTML には一切含まれません。
window や document は存在しない
Node.js には DOM がないため、レンダリング中に window.innerWidth や document.querySelector() に触れると ReferenceError になります。localStorage や navigator も同様です。こうしたブラウザ専用の API を読む処理は、レンダリング中ではなく useEffect の中に移すのが基本です。
イベントハンドラーは HTML に出力されない
onClick などのハンドラーは HTML 属性として書き出されません。ハイドレーションが完了して初めてクリックに反応するようになります。JavaScript の読み込みが遅いと、見えているのに押せない時間が生まれるので、SSR したページではこの時間差を意識しておくとよいでしょう。ハイドレーションしない renderToStaticMarkup() の出力では、ハンドラーは最後まで動きません。
ストリーミングやデータ待ちには対応できない
renderToString() の最大の制約は、ストリーミングやデータの待機に対応していないことです。全体を同期的に一度でレンダリングして文字列を返すため、途中で非同期処理の完了を待つことができません。Suspense のサポートも限定的で、コンポーネントがサスペンドした場合、そのフォールバックがそのまま HTML として出力されます。renderToStaticMarkup() も同じ制約を持ちます。
つまり、サーバー側でデータを取得してから中身を描画したい、という使い方はできません。ローディング表示だけが入った HTML が返り、実際のデータはクライアントで取り直すことになります。また、レンダリングが終わるまでレスポンスを1文字も返せないので、重いページでは最初のバイトが届くまでの時間がそのまま伸びます。
React 18 以降、サーバーでのレンダリングにはストリーミング対応の API が用意されています。実行環境によって使うものが変わります。
| API | 特徴 |
|---|---|
renderToPipeableStream | Node.js のストリームへ書き出す。Node.js 環境での推奨 |
renderToReadableStream | Web Streams を使う。エッジランタイムや Deno など Web 標準の環境向け |
renderToString | ストリームが使えない環境向け。データ待ちやストリーミングは不可 |
renderToStaticMarkup | ハイドレーションしない静的 HTML の生成用 |
ストリーミング系の API は、準備できた部分から順に HTML を送り出し、サスペンドした部分は後から追いつく形で埋めていきます。Node.js で SSR を自前で組むなら、次のように renderToPipeableStream を使うのが基本形です。
import { renderToPipeableStream } from 'react-dom/server';
import App from './App';
app.get('/', (req, res) => {
const { pipe } = renderToPipeableStream(<App />, {
// クライアントで実行するスクリプト
bootstrapScripts: ['/static/client.js'],
// シェル(Suspense の外側)の描画が終わった時点で送り始める
onShellReady() {
res.setHeader('Content-Type', 'text/html');
pipe(res);
},
});
});
renderToString() を選ぶ理由があるのは、ストリームを扱えない環境や、静的な HTML を一度だけ生成すればよい処理、テストで出力を文字列として比較したい場面などです。逆に本番の SSR ページを新しく作るなら、はじめからストリーミング系を検討したほうがよいでしょう。
ハイドレーションの警告が出るとき
renderToString() と hydrateRoot() を組み合わせると、コンソールに「Hydration failed because the server rendered HTML didn’t match the client」という類のメッセージが出ることがあります。これは、サーバーが出力した HTML と、クライアントでの初回レンダリング結果が一致しなかったという意味です。
実行ごとに変わる値を描画している
最も多いのがこれです。new Date() や Math.random() をレンダリング中に呼ぶと、サーバーで実行した時点の値とブラウザで実行した時点の値が違うため、必ずずれます。ユーザーのタイムゾーンに依存する toLocaleString() なども、サーバーとブラウザで結果が変わるので同じ問題を起こします。
対処は、初回レンダリングではサーバーと同じ内容を返しておき、マウント後に useEffect で本来の値へ差し替えることです。useEffect がサーバーで実行されない性質を逆に利用します。
import { useEffect, useState } from 'react';
export function CurrentTime() {
// サーバーでもクライアントでも初回は null
const [time, setTime] = useState<string | null>(null);
useEffect(() => {
// ブラウザでのマウント後にだけ実行される
setTime(new Date().toLocaleTimeString());
}, []);
// 初回レンダリングはサーバーと同じ出力にしておく
if (time === null) {
return <p>読み込み中</p>;
}
return <p>現在時刻: {time}</p>;
}
ずれても構わない小さな箇所なら、その要素に suppressHydrationWarning={true} を付けて警告だけを抑える方法もあります。ただし効くのは1階層分だけで、内容の不一致そのものを直してくれるわけではないため、例外的な手段と考えてください。
サーバーとクライアントで違うツリーを渡している
renderToString(<App />) と hydrateRoot(container, <App />) で、渡すコンポーネントや props が違っていないか確認します。<StrictMode> のような外側のラッパーを片方だけに付けている、サーバーでは props を渡しているのにクライアントでは渡していない、といったずれもよくある原因です。サーバーで使った初期データは HTML と一緒に埋め込んでおき、クライアントでも同じ値を使うようにします。
マウント先の指定が食い違っている
renderToString() の出力を <div id="root"> の中に埋め込んだなら、クライアントでも hydrateRoot() の第1引数はその <div id="root"> でなければなりません。ラッパーを1つ余分に挟んだり、埋め込み位置と別の要素を指定したりすると、React から見て構造が違うので不一致になります。
Next.js を使っているなら直接呼ぶ必要はない
ここまでの内容は、SSR の仕組みを自分で組み立てる場合の話です。Next.js のようなフレームワークを使っているなら、サーバーでのレンダリングとクライアントでのハイドレーションはフレームワークが内部で処理しているため、renderToString() をアプリのコードに書くことはまずありません。書くのはページやコンポーネントで、それをどう HTML にするかはフレームワークの担当です。
それでも知っておく価値があるのは、フレームワークの上で起きるトラブルの多くが、この記事の内容と同じ理屈で説明できるからです。window を参照して ReferenceError になる、時刻を描画してハイドレーションの警告が出る、といった現象は、サーバーで一度 HTML を作ってからブラウザで React をつなぎ直しているという前提を知っていれば原因を絞り込めます。逆に、メール本文の HTML を組み立てるといった用途であれば、フレームワークを使っていても renderToStaticMarkup() を直接呼ぶ場面はあります。
まとめ
renderToString(reactNode, options?) は React ツリーを HTML 文字列に変換する API で、出力をクライアントの hydrateRoot() でハイドレートして動かします。renderToStaticMarkup(reactNode, options?) は React が内部で使う余分な属性を含まない HTML を返し、ハイドレーションはできないため、メール本文や静的な HTML の生成に向いています。どちらも同期的に一度でレンダリングするため、ストリーミングやデータの待機には対応せず、サスペンドしたコンポーネントはフォールバックがそのまま HTML になります。サーバーでは useEffect が実行されず、window や document も使えず、イベントハンドラーも HTML には出力されません。React 18 以降のサーバーレンダリングでは、Node.js なら renderToPipeableStream、Web Streams 環境なら renderToReadableStream が推奨で、renderToString は限定的な用途向けと位置づけられています。ハイドレーションの警告が出たら、時刻や乱数のように実行ごとに変わる値を描画していないか、サーバーとクライアントで同じツリーを渡しているかを順に確認してください。