- React
React createPortalの使い方|モーダルとz-index
createPortalとは、コンポーネントの中身を、DOM上の別の場所(多くは document.body の直下)に描画するためのReactのAPIです。 モーダルやドロップダウンを親要素の中にそのまま置くと、親のCSSの影響で一部が切れたり、ほかの要素の下に隠れたりします。createPortalで描画先だけを外へ移すと、コンポーネントの書き方やstateの持ち方はそのままに、この問題を避けられます。
この記事では、親のCSSでモーダルが切れる・重なり順で負ける状態をChromiumで再現し、要素の位置と「その座標でいちばん手前にある要素」を計測して、createPortalで直る前後を数値で比べます。 あわせて、DOM上は外にあってもクリックやContextがReactツリーを伝わる挙動、stopPropagationの使いどころ、Esc・背景クリックで閉じる処理、フォーカストラップとスクロール固定、サーバー側の描画で document が無い場合の書き方、dialog要素との使い分けまでを、動かしたコードと結果で示します。
確認環境:Ubuntu 24.04、Node.js 22.22、React 19.3.0 / react-dom 19.3.0、TypeScript 7.0.2。esbuild 0.28.2でまとめ、Playwright 1.56.0のChromium 141(画面サイズ800×600)で動かしました。開発ビルドと本番ビルドで同じ結果です。
createPortalの早見表
| 困りごと | 原因 | 直し方 |
|---|---|---|
| モーダルが親の枠の中に閉じ込められて切れる | 親に transform があり、position: fixed の基準が親になっている |
createPortalで document.body に描画する |
z-index: 9999 にしてもヘッダーの下に隠れる |
親が z-index を持ち、重なりの比較が親どうしで決まっている |
createPortalで親の外に出す |
ドロップダウンが親の overflow: hidden で切れる |
position: absolute の要素は親の枠で切り取られる |
createPortalで外に出し、位置はボタンの座標から計算する |
| モーダルの中をクリックしたら、一覧の行のonClickまで動いた | イベントはDOMではなくReactツリーを伝わる | オーバーレイで e.stopPropagation() |
サーバー側の描画で document is not defined |
描画中に document を読んでいる |
useEffect のあとで描画する |
| Escで閉じない、Tabで背景に移動してしまう | ポータルは描画先を変えるだけで、モーダルの振る舞いは付かない | キー操作とフォーカスの処理を書く。またはdialog要素を使う |
| 項目 | 内容 |
|---|---|
| 書き方 | createPortal(children, domNode, key?) |
| import元 | react-dom |
| children | 描画する内容(JSX、文字列、数値、それらの配列など) |
| domNode | 描画先のDOM要素。すでに存在している要素を渡す |
| key | 省略可。ポータルを区別するための文字列か数値 |
| 変わるもの | DOM上の位置だけ |
| 変わらないもの | Reactツリー上の親子関係(props、state、Context、イベントの伝わり方) |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「React createPortalの使い方|モーダルとz-index」
createPortalの基本
createPortal は react-dom からimportし、JSXの中に置いて使います。 次のコンポーネントは、usePortal がtrueのときだけオーバーレイを document.body に描画します。このあとの再現でも、このコンポーネントを使います。
// src/Scene.tsx
import { createPortal } from "react-dom";
type Props = {
parentClass: string; // 親に付けるクラス(clip / transform / stack)
usePortal: boolean;
};
export function Scene({ parentClass, usePortal }: Props) {
const overlay = (
<div className="overlay">
<div className="modal">modal</div>
</div>
);
return (
<>
<header className="header">header</header>
<div className={"card " + parentClass}>
card
{usePortal ? createPortal(overlay, document.body) : overlay}
</div>
</>
);
}
変わるのは描画先だけです。Scene から見れば、オーバーレイは今までどおり自分の子で、propsやstateを同じように渡せます。
再現:親のCSSでモーダルが切れる・重なり順で負ける
用意したCSS
/* styles.css */
body { margin: 0; }
.header { position: sticky; top: 0; z-index: 2; height: 50px; background: #234; color: #fff; }
.card { margin: 20px; width: 300px; height: 150px; border: 1px solid #999; }
.clip { overflow: hidden; }
.transform { transform: translateZ(0); }
.stack { position: relative; z-index: 1; }
.overlay { position: fixed; inset: 0; z-index: 9999; background: rgba(0, 0, 0, 0.5); display: grid; place-items: center; }
.modal { background: #fff; padding: 24px; min-width: 240px; }
.menu-box { position: relative; height: 60px; }
.menu { margin: 0; padding: 0; list-style: none; width: 160px; height: 120px; background: #fff; border: 1px solid #333; }
.menu-absolute { position: absolute; top: 30px; left: 0; }
.tall { height: 2000px; }
画面の上にヘッダー(z-index: 2)があり、その下に幅300px・高さ150pxのカードがあります。オーバーレイは position: fixed; inset: 0; z-index: 9999 で、本来は画面全体を覆ってヘッダーより手前に出るつもりのスタイルです。カードに付けるクラスを変えて、3通りの親を試します。
| 親のクラス | 親のCSS |
|---|---|
clip |
overflow: hidden |
clip transform |
overflow: hidden と transform: translateZ(0) |
stack |
position: relative と z-index: 1 |
計測の方法
各パターンを描画したあと、ブラウザの中で次の3つを取りました。
- オーバーレイの位置と大きさ(
getBoundingClientRect()の x, y, 幅, 高さ) - ヘッダーの中の点 (400, 25) で、いちばん手前にある要素(
document.elementFromPoint()) - カードから離れた点 (700, 500) で、いちばん手前にある要素
計測結果
{"scene":"clip-inline","overlayRect":"0,0,800,600","at(400,25)":"overlay","at(700,500)":"overlay","overlayParent":"card clip"}
{"scene":"clip-portal","overlayRect":"0,0,800,600","at(400,25)":"overlay","at(700,500)":"overlay","overlayParent":"body"}
{"scene":"transform-inline","overlayRect":"21,71,300,150","at(400,25)":"header","at(700,500)":"html","overlayParent":"card clip transform"}
{"scene":"transform-portal","overlayRect":"0,0,800,600","at(400,25)":"overlay","at(700,500)":"overlay","overlayParent":"body"}
{"scene":"stack-inline","overlayRect":"0,0,800,600","at(400,25)":"header","at(700,500)":"overlay","overlayParent":"card stack"}
{"scene":"stack-portal","overlayRect":"0,0,800,600","at(400,25)":"overlay","at(700,500)":"overlay","overlayParent":"body"}
表にまとめます。「そのまま」は親の中に直接置いた場合、「ポータル」はcreatePortalで document.body に描画した場合です。
| 親のCSS | 置き方 | オーバーレイの大きさ | ヘッダーの位置で手前にあるもの | 結果 |
|---|---|---|---|---|
overflow: hidden だけ |
そのまま | 800×600 | オーバーレイ | 画面全体を覆えている |
overflow: hidden だけ |
ポータル | 800×600 | オーバーレイ | 同じ |
overflow: hidden と transform |
そのまま | 300×150 | ヘッダー | カードの中に閉じ込められる |
overflow: hidden と transform |
ポータル | 800×600 | オーバーレイ | 画面全体を覆う |
z-index: 1 |
そのまま | 800×600 | ヘッダー | 大きさは正しいが、ヘッダーの下に隠れる |
z-index: 1 |
ポータル | 800×600 | オーバーレイ | ヘッダーより手前に出る |
なぜそうなるのか
1. overflow: hidden だけでは、position: fixed のモーダルは切れませんでした。 position: fixed の要素は画面(ビューポート)を基準に配置されるため、途中の親の overflow の切り取りを受けません。
2. 親に transform があると、position: fixed の基準がその親に変わります。 計測では、オーバーレイの位置と大きさが (21, 71) から300×150、つまりカードの内側とぴったり同じになりました。inset: 0 の「0」がカードの端を指すようになり、さらに overflow: hidden で外側が切り取られます。アニメーション用に transform を付けたカードやスライドの中でモーダルが小さくなるのは、この組み合わせです。
3. 親が z-index を持つと、子の z-index はその親の中だけの順番になります。 position と z-index を持つ要素は「スタッキングコンテキスト」と呼ばれる重なりのグループを作ります。グループどうしの前後は親の z-index で決まるため、親が1、ヘッダーが2なら、子に9999を付けてもヘッダーより手前には出られません。
どちらも、モーダルのCSSではなく、親のCSSが原因です。親のCSSを変えられれば直りますが、共通部品のモーダルを「どの親の中に置かれても表示できる」ようにするには、描画先を親の外に出すのが確実です。
DOM構造の前と後
transform のパターンで、body の中のHTMLを取り出した結果です(読みやすいよう改行を入れています)。
そのまま置いた場合:
<div id="root">
<header class="header">header</header>
<div class="card clip transform">
card
<div class="overlay"><div class="modal">modal</div></div>
</div>
</div>
createPortalを使った場合:
<div id="root">
<header class="header">header</header>
<div class="card clip transform">card</div>
</div>
<div class="overlay"><div class="modal">modal</div></div>
オーバーレイが #root の外、body の直下に移りました。カードの transform や overflow、z-index の影響を受ける祖先がいなくなったため、画面全体を基準に配置され、z-index: 9999 がヘッダーの2と直接比べられます。
ドロップダウンは overflow: hidden だけで切れる
position: absolute で出すメニューは、親の overflow: hidden だけで切れます。高さ60pxのカードの中に、高さ120pxのメニューを出すコンポーネントで確かめました。
// src/MenuScene.tsx
import { useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
const items = (
<>
<li>edit</li>
<li>copy</li>
<li>delete</li>
</>
);
export function MenuScene({ usePortal }: { usePortal: boolean }) {
const buttonRef = useRef<HTMLButtonElement>(null);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
// ポータルにすると親からの相対位置が使えないので、ボタンの位置から計算する
useLayoutEffect(() => {
const r = buttonRef.current!.getBoundingClientRect();
setPos({ top: r.bottom, left: r.left });
}, []);
return (
<div className="card clip menu-box">
<button ref={buttonRef}>menu</button>
{usePortal ? (
pos &&
createPortal(
<ul className="menu" style={{ position: "fixed", top: pos.top, left: pos.left }}>
{items}
</ul>,
document.body,
)
) : (
<ul className="menu menu-absolute">{items}</ul>
)}
</div>
);
}
{"scene":"menu-inline","cardRect":"20,20,302,62","menuRect":"21,51,162,122","at(60,150)":"html"}
{"scene":"menu-portal","cardRect":"20,20,302,62","menuRect":"21,42,162,122","at(60,150)":"menu"}
そのまま置いた場合、メニューの大きさは162×122で計算上は存在しますが、カード(下端はy=82)の外にあたる点 (60, 150) では html が手前にあり、メニューは見えていません。 ポータルにすると、同じ点でメニューが手前に来ました。
ポータルにすると親からの相対位置(top: 30px など)が使えなくなるため、ボタンの座標を getBoundingClientRect() で取り、position: fixed で配置しています。スクロールや画面サイズの変更に追従させたい場合は、そのたびに座標を取り直す処理を足します。
DOMの外に出ても、イベントとContextはReactツリーを伝わる
createPortalで変わるのはDOM上の位置だけです。Reactの公式ドキュメントにも、ポータルからのイベントはDOMツリーではなくReactツリーに沿って伝わると書かれています。実際に確かめます。
// src/BubbleDemo.tsx
import { createContext, useContext, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
const ThemeContext = createContext("light");
type Log = (line: string) => void;
function PortalChild({ log, stop }: { log: Log; stop: boolean }) {
const theme = useContext(ThemeContext); // Reactツリー上の親から受け取る
return (
<div id="portal-box" onClick={stop ? (e) => e.stopPropagation() : undefined}>
<button id="in-portal" onClick={() => log("React: ポータル内ボタンの onClick(theme=" + theme + ")")}>
in portal
</button>
</div>
);
}
export function BubbleDemo({ log, stop }: { log: Log; stop: boolean }) {
const parentRef = useRef<HTMLDivElement>(null);
// 比較用:React を通さず、DOM に直接付けたリスナー
useEffect(() => {
const el = parentRef.current!;
const handler = () => log("DOM: 親 div の addEventListener");
el.addEventListener("click", handler);
return () => el.removeEventListener("click", handler);
}, [log]);
return (
<ThemeContext value="dark">
<div
id="parent"
ref={parentRef}
onClick={(e) => {
const target = e.target as HTMLElement;
log("React: 親 div の onClick(target=#" + target.id + "、DOM上で親の中か=" + e.currentTarget.contains(target) + ")");
}}
>
<button id="inside">inside</button>
{createPortal(<PortalChild log={log} stop={stop} />, document.body)}
</div>
</ThemeContext>
);
}
描画後のDOMは次のとおりで、ポータルの中身(#portal-box)は #parent の外にあります。
<div id="root">
<div id="parent"><button id="inside">inside</button></div>
</div>
<div id="portal-box"><button id="in-portal">in portal</button></div>
ポータルの中のボタン、親の中のボタンを順にクリックした記録です(stop はfalse)。
{"click":"#in-portal","logs":["React: ポータル内ボタンの onClick(theme=dark)","React: 親 div の onClick(target=#in-portal、DOM上で親の中か=false)"]}
{"click":"#inside","logs":["DOM: 親 div の addEventListener","React: 親 div の onClick(target=#inside、DOM上で親の中か=true)"]}
読み取れることは3つです。
- ポータルの中のクリックで、親divのReactの
onClickが呼ばれました。 クリックされた要素はDOM上では親の外にあります(containsがfalse)。 - DOMに直接付けた
addEventListenerは呼ばれていません。 DOMツリーでは#in-portalの祖先に#parentがいないためです。 - Contextの値(
dark)は、ポータルの中でもそのまま受け取れました。 Reactツリー上では、親の内側にいるためです。
| 伝わり方 | 基準になるツリー | ポータルの中から親へ |
|---|---|---|
Reactのイベント(onClick など) |
Reactツリー | 伝わる |
| Context | Reactツリー | 受け取れる |
DOMの addEventListener |
DOMツリー | 伝わらない |
CSSの継承・overflow・z-index |
DOMツリー | 影響を受けない |
stopPropagationの使いどころ
Reactツリーを伝わる性質は、Contextをそのまま使える点では便利です。一方で、クリックできる親の中にモーダルを置くと、モーダルの中の操作が親のonClickまで届きます。 「行をクリックすると詳細へ移動する一覧」の行の中に削除確認のモーダルを置いた、という場面が典型です。
ポータルの中身を包む要素で e.stopPropagation() を呼ぶと、そこで止まります。stop をtrueにして同じ操作をした記録です。
{"click":"#in-portal (stopPropagation あり)","logs":["React: ポータル内ボタンの onClick(theme=dark)"]}
親divの onClick は呼ばれなくなりました。
| 場面 | stopPropagation |
|---|---|
親や祖先に onClick があり、モーダル内の操作を伝えたくない |
オーバーレイ(ポータルのいちばん外側)で呼ぶ |
親に onClick が無い |
不要 |
| モーダルの中のボタンそれぞれ | 呼ばない。いちばん外側の1か所にまとめる |
| 分析用に、ページ全体のクリックを祖先で集めている | 止めるとモーダル内の操作が集計から抜ける。止めずに、親側で e.currentTarget.contains(e.target) を見て判断する方法もある |
完成形:Escと背景クリックで閉じ、フォーカスを閉じ込めるモーダル
ポータルは描画先を変えるだけで、モーダルとしての振る舞いは自分で書きます。公式ドキュメントでも、ポータルを使うときはキーボードのフォーカスを適切に管理するよう注意が書かれています。必要な処理を1つのコンポーネントにまとめました。
// src/Modal.tsx
import { useEffect, useRef, type MouseEvent, type ReactNode } from "react";
import { createPortal } from "react-dom";
type Props = {
open: boolean;
onClose: () => void; // useCallback などで同じ関数を渡す
title: string;
children: ReactNode;
};
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
export function Modal({ open, onClose, title, children }: Props) {
const dialogRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const dialog = dialogRef.current!;
// 開く前にフォーカスがあった要素(閉じたら戻す)
const opener = document.activeElement as HTMLElement | null;
// 1. フォーカスをモーダルの中へ移す
const first = dialog.querySelector<HTMLElement>(FOCUSABLE);
(first ?? dialog).focus();
// 2. 背景のスクロールを固定する
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKeyDown = (e: KeyboardEvent) => {
// 3. Esc で閉じる
if (e.key === "Escape") {
onClose();
return;
}
// 4. Tab をモーダルの中で循環させる(フォーカストラップ)
if (e.key !== "Tab") return;
const list = Array.from(dialog.querySelectorAll<HTMLElement>(FOCUSABLE));
if (list.length === 0) {
e.preventDefault();
return;
}
const firstItem = list[0];
const lastItem = list[list.length - 1];
if (e.shiftKey && document.activeElement === firstItem) {
e.preventDefault();
lastItem.focus();
} else if (!e.shiftKey && document.activeElement === lastItem) {
e.preventDefault();
firstItem.focus();
}
};
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = prevOverflow;
opener?.focus(); // 5. 閉じたら、開いたボタンへフォーカスを戻す
};
}, [open, onClose]);
if (!open) return null;
const onOverlayClick = (e: MouseEvent<HTMLDivElement>) => {
// 6. モーダル内のクリックを、Reactツリー上の親(行の onClick など)へ伝えない
e.stopPropagation();
// 7. 背景(オーバーレイ自身)をクリックしたときだけ閉じる
if (e.target === e.currentTarget) onClose();
};
return createPortal(
<div className="overlay" onClick={onOverlayClick}>
<div
className="modal"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
ref={dialogRef}
>
<h2 id="modal-title">{title}</h2>
{children}
</div>
</div>,
document.body,
);
}
使う側です。行全体に onClick がある一覧の1行、という想定にしています。
// src/ModalDemo.tsx
import { useCallback, useState } from "react";
import { Modal } from "./Modal";
export function ModalDemo({ log }: { log: (line: string) => void }) {
const [open, setOpen] = useState(false);
const close = useCallback(() => setOpen(false), []);
return (
// 行全体がクリックできる一覧の1行、という想定
<div id="row" className="tall" onClick={() => log("行の onClick が呼ばれた")}>
<button id="open" onClick={(e) => { e.stopPropagation(); setOpen(true); }}>
open
</button>
<span id="state">{open ? "open" : "closed"}</span>
<Modal open={open} onClose={close} title="削除の確認">
<input id="memo" placeholder="メモ" />
<button id="cancel" onClick={close}>キャンセル</button>
<button id="ok" onClick={close}>OK</button>
</Modal>
</div>
);
}
onClose には useCallback で作った関数を渡しています。描画のたびに新しい関数を渡すと、useEffect がそのつど実行し直され、フォーカスが先頭の要素へ戻ってしまうためです。
動作の記録
キーボードとマウスを実際に操作して、状態(state)、フォーカスのある要素のid(active)、body の overflow、スクロール位置を記録しました。
{"step":"初期","state":"closed","active":"body","bodyOverflow":"","scrollY":0}
{"step":"閉じた状態でホイール300","state":"closed","active":"body","bodyOverflow":"","scrollY":300}
{"step":"Enterで開く","state":"open","active":"memo","bodyOverflow":"hidden","scrollY":0,"logs":[]}
{"step":"開いた状態でホイール300","state":"open","active":"memo","bodyOverflow":"hidden","scrollY":0}
{"step":"Tab移動","seq":["Tab→cancel","Tab→ok","Tab→memo","Tab→cancel","Shift+Tab→memo","Shift+Tab→ok"]}
{"step":"モーダルの中(input)をクリック","state":"open","active":"memo","bodyOverflow":"hidden","scrollY":0,"logs":[]}
{"step":"Esc","state":"closed","active":"open","bodyOverflow":"","scrollY":0,"logs":[]}
{"step":"開いて背景(700,500)をクリック","state":"closed","active":"open","bodyOverflow":"","scrollY":0,"logs":[]}
(「Enterで開く」の前に、スクロール位置を0に戻しています)
| 機能 | コードの番号 | 記録から確かめられたこと |
|---|---|---|
| 開いたらフォーカスを中へ | 1 | 開いた直後の active が入力欄の memo |
| スクロール固定 | 2 | 閉じた状態ではホイールで300px動き、開いた状態では0のまま。閉じると overflow が元に戻る |
| Escで閉じる | 3 | state が closed になる |
| フォーカストラップ | 4 | Tabが memo → cancel → ok → memo と循環し、Shift+Tabは逆回り。背景の open ボタンへは移動しない |
| 閉じたらフォーカスを戻す | 5 | 閉じたあとの active が、開いたボタンの open |
| 親へクリックを伝えない | 6 | モーダルの中をクリックしても、行の onClick の記録(logs)が空 |
| 背景クリックで閉じる | 7 | オーバーレイの余白 (700, 500) をクリックすると closed |
stopPropagationを入れる前の記録
Modal.tsx の6番(e.stopPropagation())を入れる前に同じ操作をしたときは、次の記録になりました。
{"step":"モーダルの中(input)をクリック","state":"open","active":"memo","bodyOverflow":"hidden","scrollY":0,"logs":["行の onClick が呼ばれた"]}
{"step":"開いて背景(700,500)をクリック","state":"closed","active":"open","bodyOverflow":"","scrollY":0,"logs":["行の onClick が呼ばれた"]}
入力欄をクリックしただけで、行の onClick が呼ばれています。 画面の上では行とモーダルは離れて見えるため、気づきにくい動きです。モーダルを共通部品にするなら、オーバーレイで止めておくと、どこに置かれても同じように動きます。
背景クリックの判定は e.target と e.currentTarget を比べる
オーバーレイの onClick は、中の白い箱をクリックしたときにも呼ばれます(箱はオーバーレイの子だからです)。e.target === e.currentTarget を確かめると、オーバーレイ自身がクリックされたときだけ閉じられます。上の記録でも、入力欄のクリックでは open のまま、余白のクリックでは closed になっています。
SSRやNext.jsで document が無いときの書き方
サーバー側でReactを描画する環境(Node.js)には document がありません。描画中に document.body を読むコンポーネントを renderToString に渡して確かめました。
// src/ClientPortal.tsx
"use client";
import { useEffect, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
// サーバー側の描画と、ブラウザでの最初の描画では何も出さない
export function ClientPortal({ children }: { children: ReactNode }) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true); // useEffect はブラウザでだけ動く
}, []);
if (!mounted) return null;
return createPortal(children, document.body);
}
// src/ssr.tsx
import { type ReactNode } from "react";
import { createPortal } from "react-dom";
import { renderToString } from "react-dom/server";
import { ClientPortal } from "./ClientPortal";
import { Modal } from "./Modal";
// 直す前:描画中に document を読む
function DirectPortal({ children }: { children: ReactNode }) {
return createPortal(children, document.body);
}
export function Page({ fixed }: { fixed: boolean }) {
const toast = <p id="toast">saved</p>;
return (
<div>
<p>page</p>
{fixed ? <ClientPortal>{toast}</ClientPortal> : <DirectPortal>{toast}</DirectPortal>}
</div>
);
}
if (typeof document === "undefined") {
console.log("typeof document:", typeof document);
try {
console.log("直す前:", renderToString(<Page fixed={false} />));
} catch (e) {
console.log("直す前:", (e as Error).name + ": " + (e as Error).message);
}
console.log("直した後:", renderToString(<Page fixed={true} />));
// Modal.tsx は open が false の間は createPortal まで進まない
const modal = (open: boolean) => (
<Modal open={open} onClose={() => {}} title="確認">
<button>OK</button>
</Modal>
);
console.log("Modal open=false:", JSON.stringify(renderToString(modal(false))));
try {
console.log("Modal open=true:", renderToString(modal(true)));
} catch (e) {
console.log("Modal open=true:", (e as Error).name + ": " + (e as Error).message);
}
}
npx esbuild src/ssr.tsx --bundle --platform=node --jsx=automatic --define:process.env.NODE_ENV='"production"' --outfile=ssr.cjs
node ssr.cjs
typeof document: undefined
直す前: ReferenceError: document is not defined
直した後: <div><p>page</p></div>
Modal open=false: ""
Modal open=true: ReferenceError: document is not defined
直す前は、描画の途中で document を読んだ時点で例外になります。ClientPortal は、useEffect が動いたあと(=ブラウザで画面に反映されたあと)で初めて createPortal を呼ぶため、サーバー側では何も出力せずに通ります。
ブラウザで引き継いだあとの動き
サーバー側で作ったHTMLをページに置き、hydrateRoot で引き継いだ(ハイドレーション)前後の body の中身です。
hydrate前: <div id="root"><div><p>page</p></div></div>
hydrate後: <div id="root"><div><p>page</p></div></div><p id="toast">saved</p>
{"toastParent":"body","logs":[],"consoleErrors":[]}
引き継ぎのあとで、ポータルの中身が body の直下に追加されました。サーバー側のHTMLとブラウザでの最初の描画がどちらも「何も出さない」で一致するため、onRecoverableError への通知もコンソールのエラーも出ていません。
| 書き方 | サーバー側の描画 | 引き継ぎ |
|---|---|---|
描画中に createPortal(children, document.body) |
ReferenceError: document is not defined |
― |
useEffect のあとで createPortal を呼ぶ(ClientPortal) |
何も出力せずに通る | エラーなしで、あとからポータルが追加される |
開いたときだけ描画する(if (!open) return null を先に書く) |
open の初期値がfalseなら document を読まない |
開く操作はブラウザで起きるため、そのまま動く |
先ほどの Modal.tsx は3番目の形です。上の出力の最後の2行のとおり、open がfalseなら空文字を返して通り、trueで描画すると例外になりました。最初から開いた状態で描画する可能性がある場合は、ClientPortal の形にします。
Next.jsで使うとき
Next.jsの公式ドキュメント(16.3.8)では、'use client' はクライアント側で描画するコンポーネントの入口を宣言するもので、state、イベント処理、ブラウザAPIへのアクセスなどが必要なときに使う、と説明されています。useState や useEffect を使うポータルのコンポーネントは、上の ClientPortal.tsx のようにファイルの先頭に "use client"; を書きます。 そのうえで、document を読む処理を useEffect のあとに回しておけば、Node.js上の renderToString で確かめたとおり、document が無い環境で描画されても例外になりません。
dialog要素との使い分け
HTMLのdialog要素を showModal() で開く方法もあります。overflow: hidden と transform を持つカード、つまり先ほどポータルなしではモーダルが閉じ込められた親の中に、dialog要素をそのまま置いて確かめました。
// src/DialogDemo.tsx
import { useRef } from "react";
export function DialogDemo({ log }: { log: (line: string) => void }) {
const ref = useRef<HTMLDialogElement>(null);
return (
<>
<header className="header">header</header>
{/* overflow: hidden と transform を持つ親の中に、そのまま置く */}
<div className="card clip transform">
<button id="open-dialog" onClick={() => ref.current?.showModal()}>
open
</button>
<dialog id="dlg" ref={ref} onClose={() => log("close イベント")}>
<p>dialog</p>
<input id="dlg-memo" />
<form method="dialog">
<button id="dlg-ok">OK</button>
</form>
</dialog>
</div>
</>
);
}
{"step":"showModal","open":true,"dialogRect":"289,235,223,130","at(400,25)":"dlg","at(700,500)":"dlg","active":"dlg-memo"}
{"step":"Tab移動","seq":["Tab→dlg-ok","Tab→body","Tab→dlg-memo"]}
{"step":"Esc","open":false,"active":"open-dialog","logs":["close イベント"]}
- ポータルを使っていないのに、画面の中央(x=289〜512)に表示されました。 カードの枠(幅300px)には閉じ込められていません。
- ヘッダーの位置 (400, 25) でも、離れた点 (700, 500) でも、いちばん手前にあるのはdialog要素(背景の幕を含む)でした。
- 開くと、最初のフォーカスできる要素(入力欄)にフォーカスが移りました。
- Tabは入力欄 → OKボタン →
body→ 入力欄と進み、背景のopen-dialogボタンには移動しませんでした(途中のbodyは、ページ内のどの要素にもフォーカスが無い状態です)。 - Escで閉じ、
closeイベントが発生し、フォーカスは開いたボタンに戻りました。
これらは、ブラウザが用意している動きです。MDNにも、showModal() で開いたdialog要素はほかの要素の操作をブロックすること、Escで閉じられること、開いたときに最初のフォーカスできる要素にフォーカスが移ることが書かれています。背景の幕は ::backdrop 疑似要素でスタイルを付けます。
| 比べる点 | createPortalで作るモーダル | dialog要素と showModal() |
|---|---|---|
親の transform や z-index の影響 |
body に出すことで避ける |
受けなかった(今回の計測) |
| Escで閉じる | 自分で書く | ブラウザが行う |
| フォーカスを中へ移す・戻す | 自分で書く | ブラウザが行う |
| 背景への移動を防ぐ | フォーカストラップを自分で書く | ブラウザが背景を操作できない状態にする |
| 背景クリックで閉じる | e.target === e.currentTarget で判定 |
closedby 属性(MDNに記載)か、自分でクリックを判定 |
| 背景の幕 | オーバーレイの要素を自分で置く | ::backdrop 疑似要素 |
| 開閉の持ち方 | Reactのstate(open)だけで決まる |
showModal() / close() を呼ぶ。stateと合わせるなら useEffect と onClose で同期する |
| 向いているもの | トースト、ツールチップ、ドロップダウン、独自の動きをするパネル | 確認ダイアログ、入力フォームのモーダル |
「画面の操作を止めて答えを求める」モーダルにはdialog要素、それ以外の「親の外に出したいUI」にはcreatePortal、と考えると選びやすくなります。なお、MDNには、dialog要素に tabindex 属性を付けないようにという注意があります。
つまずきやすい点と直し方
| 起きること | 理由 | こうするとよい |
|---|---|---|
z-index を大きくしても手前に出ない |
親が作る重なりのグループの中だけの順番になっている | ポータルで親の外に出す |
position: fixed なのに親の中に収まる |
親に transform がある |
ポータルで親の外に出す |
| モーダルの中のクリックで親の処理が動く | イベントはReactツリーを伝わる | オーバーレイで stopPropagation |
| 中の箱をクリックしても閉じてしまう | オーバーレイの onClick は子のクリックでも呼ばれる |
e.target === e.currentTarget を確かめる |
| 開くたびにフォーカスが先頭へ戻る | onClose が毎回新しい関数で、useEffect が実行し直される |
useCallback で同じ関数を渡す |
document is not defined |
サーバー側の描画中に document を読んでいる |
useEffect のあとで createPortal を呼ぶ |
| ポータルにしたメニューの位置がずれる | 親からの相対位置が使えない | ボタンの getBoundingClientRect() から計算する |
| 閉じたあと、キーボードの位置がわからなくなる | フォーカスを戻していない | 開く前の document.activeElement を覚えておき、閉じたら focus() |
よくある質問
Q. ポータルの描画先は、document.body以外でもよいですか?
A. はい。createPortal の第2引数には、すでに存在しているDOM要素を渡せます。HTMLに modal-root のようなidの要素を用意して document.getElementById() で渡す形もよく使われます。重なり順を管理しやすいよう、#root の兄弟として body の直下に置きます。
Q. ポータルの中から、親のstateやContextは使えますか?
A. 使えます。今回の確認でも、ポータルの中のコンポーネントが、親で渡したContextの値(dark)を受け取れました。propsも通常の子と同じように渡せます。変わるのはDOM上の位置だけです。
Q. ポータルにすれば、z-indexは指定しなくてよいですか?
A. 指定は必要です。ポータルは「親の重なりのグループから出る」ための手段で、出たあとは body 直下の要素どうしで z-index が比べられます。今回は z-index: 9999 のオーバーレイが、z-index: 2 のヘッダーより手前に出ました。モーダル、トースト、ツールチップの順番は、変数などで一覧にして決めておくと管理しやすくなります。
Q. モーダルは、createPortalとdialog要素のどちらで作るのがよいですか? A. 確認や入力のためのモーダルなら、Escで閉じる、フォーカスを移す、背景を操作できなくする、といった動きをブラウザが受け持つdialog要素が手軽です。開閉を完全にReactのstateで決めたい場合や、トースト・ドロップダウンのようにモーダルではないUIには、createPortalが合います。
まとめ
- createPortalは、Reactツリー上の位置はそのままに、DOM上の描画先だけを変える。 書き方は
createPortal(children, domNode) - 親に
transformがあると、position: fixedのモーダルは親の中に閉じ込められる。 計測では800×600のはずが300×150になり、ポータルで直った - 親が
z-indexを持つと、子のz-index: 9999はヘッダーのz-index: 2に勝てない。 ポータルで親の外に出すと、手前に出た - イベントとContextはReactツリーを伝わる。 ポータルの中のクリックは親の
onClickに届き、DOMのaddEventListenerには届かない - モーダルの中の操作を親へ伝えたくないときは、オーバーレイの1か所で
stopPropagation - Esc、背景クリック、フォーカストラップ、フォーカスの復帰、スクロール固定は自分で書く。 dialog要素なら、多くをブラウザが受け持つ
- サーバー側の描画では
documentが無い。useEffectのあとでcreatePortalを呼ぶ形にする
参考・出典
- React公式ドキュメント「createPortal」 https://ja.react.dev/reference/react-dom/createPortal
- Next.js公式ドキュメント「use client」 https://nextjs.org/docs/app/api-reference/directives/use-client
- MDN「dialog: The Dialog element」 https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「React createPortalの使い方|モーダルとz-index」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。