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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?