どうも、ノマドクリエイターのショウヘイ(@shohei_creator)です。
ブログアシスタントのふーちゃんです。
React に限らず、 HTML をベタ書きした場合にも共通して言えることですが、子要素の style は、親要素の style による制限を受けます。 over-flow プロパティや z-idnex プロパティなど、表示の範囲や優先度に関する style が関わると、親要素からの制限を強く影響します。
React には、親要素の style の制限から子要素を逃すための機能として、 Portal (ポータル)が用意されています。 Portal を活用すれば、任意の DOM ノードに子要素をマウント(追加)できるようになります。
この記事では、 React の Portal の使い方について説明します。
React の Portal とは
React の Portal は、親コンポーネントの DOM とは別の DOM ノードに対して、子のコンポーネントをマウントできる機能です。
React は、ビルドによって、 HTML や CSS も生成されます。そのため、『 HTML の階層構造』と『 CSS の style の優先度』の影響を受けることになります。
React の単方向データフローによるコンポーネント構造を活かしつつ、かつ HTML ・ CSS の影響から子コンポーネントを逃すための方法として、 Portal が導入されたのだと思います。
React の Portal の必要性って、分かるようで……ちょっと分からないです。
使いどころが想像しづらいですね。
具体例を見てみないと、必要性が分かりにくいよね。
順を追って説明していこう。
React の Portal の基本構文と使い方
React の Portal の実装例に入る前に、まずは基本構文と使い方を説明します。
React の Portal の基本構文は、 ReactDOM の createPortal メソッドに対して、引数としてマウントする要素(子コンポーネント)とマウント先の DOM 要素を渡す形になっています。
ReactDOM.createPortal(child, container)
React の Portal を使う前の準備として、まずは親コンポーネントが設置されるであろう階層に、マウント先となる HTML タグを設置しておきます。
create-react-app コマンドを使って React アプリを作っているなら、 public フォルダの index.html に書かれている <div id=”root”></div> の隣に、マウント先となる HTML タグを設置すればいいです。
<!-- 「create-react-app」コマンドで生成されたReactアプリのindex.html -->
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="theme-color" content="#000000" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
<title>React App</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
<!-- Portalを使ってマウントする予定のdivタグ -->
<div id="mounted"></div>
</body>
</html>
次に、 Portal によってマウントさせたいコンポーネントを作っていきます。
汎用性を高めるために、内部に createPortal メソッドを持つラップ用コンポーネントを用意しておきます。
ちなみに、ラップ用コンポーネントを用意するかどうかは任意です。不要なら、子コンポーネントの中で createPortal メソッドを使って構いません。
import React from "react";
import ReactDOM from "react-dom";
type Props = {
children: React.ReactChild;
};
export const WrapPortal: React.VFC<Props> = ({ children }) => {
const el = document.getElementById("mounted");
return ReactDOM.createPortal(children, el);
};
マウントさせたい子コンポーネントを用意します。とりあえず、簡易構成にしました。
import React from "react";
export const Children: React.VFC = () => {
return <p>子コンポーネント</p>;
};
App.tsx ファイルにて、先ほど作った WrapPortal.tsx ファイルと Children.tsx ファイルを import します。また、 WrapPortal タグの中に Children タグを置きます。
import { WrapPortal } from "./WrapPortal";
import { Children } from "./Children";
export default function App() {
return (
<WrapPortal>
<Children />
</WrapPortal>
);
}
ビルドされた状態をディベロッパーツールで見てみると、 <div id=”mounted”>タグに対して、 ラップ用コンポーネントがマウントしていることが確認できます。
App コンポーネントの子コンポーネントは、本来なら <div id=”root”>タグにマウントされるはずですよね。
でも、ラップ用コンポーネントは、 <div id=”mounted”> タグの方にマウントされています!
コンポーネントの階層構造を無視して、親コンポーネントとは別の DOM に対して、子コンポーネントをマウントできる。
これが React の Portal の特徴だね。
React の Portal が必要になる実装例(モーダルウィンドウ)
React の Portal が活躍する場面は、親コンポーネントの style に over-flow プロパティや z-idnex プロパティなどが使われている実装です。
まずは、 React の Portal を使わない場合の具体例として、モーダルウィンドウと『高階層に固定された要素』を併用するページの実装を紹介します。
ここでいうところの『高階層に固定された要素』 は、グローバルメニュー・トップスクロールボタン・バナーなどのようなものです。 z-index プロパティの値が大きく、かつ positon プロパティに「 fixled 」が使われていることが特徴です。
import { Parent } from "./Parent";
import { FixedHigherLevel } from "./FixedHigherLevel";
import { Container } from "react-bootstrap";
export default function App() {
return (
<Container className="mt-4">
{/* 親コンポーネントと同階層に、固定要素コンポーネントを設置*/}
<Parent />
<FixedHigherLevel />
</Container>
);
}
import React, { useState } from "react";
import { Alert, Button } from "react-bootstrap";
import { Modal } from "./Modal";
export const Parent: React.VFC = () => {
const [show, setShow] = useState<boolean>(false);
const handleClose = (): void => setShow(false);
const handleShow = (): void => setShow(true);
return (
<Alert variant="primary" style={{ position: "relative", zIndex: 100 }}>
<p>親コンポーネント</p>
<p>z-index:100</p>
<Button variant="success" onClick={handleShow}>
モーダルウィンドウを開く
</Button>
<Modal show={show} handleClose={handleClose} />
</Alert>
);
};
import React, { useEffect, useRef } from "react";
import "./modal.css";
type Props = {
show: boolean;
handleClose: () => void;
};
export const Modal: React.VFC<Props> = ({ show, handleClose }) => {
const overlayRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const dom = overlayRef.current;
if (dom) {
if (show) {
dom.style.display = "flex";
} else {
dom.style.display = "none";
}
}
}, [show]);
return (
<div className="overlay" ref={overlayRef}>
<div className="content">
<h1>モーダルウィンドウ</h1>
<p>モーダルウィンドウのz-indexは、1000です。</p>
<p>でも、固定コンポーネントよりも下に表示されてしまっています。</p>
<button onClick={handleClose}>閉じる</button>
</div>
</div>
);
};
import React from "react";
import { Alert } from "react-bootstrap";
export const FixedHigherLevel: React.VFC = () => {
return (
<Alert
variant="warning"
style={{ position: "fixed", right: "20px", bottom: "40px", zIndex: 200 }}
>
<p>固定コンポーネント</p>
<p> z-index:200</p>
</Alert>
);
};
モーダルウィドウ( z-index:1000 )よりも、固定コンポーネント( z-index:200 )の方が優先して表示されることに注目してください。不思議に見えるかもしれませんが、 z-index プロパティの正しい挙動です。
z-index プロパティ は、要素間の表示順の絶対的な優先度を意味しません。実際の機能は、兄弟要素の間における優先度を決めるためのものです。
最初の比較は、親コンポーネント( z-index:100 )と固定コンポーネント( z-index:200 )でおこなわれています。この時点で、固定コンポーネントの方が優先して表示されることが確定します。
モーダルウィンドウの z-index プロパティは 1000 になっていますが、この優先度は、あくまでも『モーダルウィンドウの兄弟要素』に対して適用されるものです。
固定コンポーネントは、親コンポーネントよりも優先度が上です。たとえモーダルウィンドウの z-index プロパティの値が高かろうと、 固定コンポーネント の方が優先して表示されます。
z-index プロパティの値って、兄弟要素に対する優先度を意味するものなんですね。
てっきり、絶対的な優先度を調整できる CSS プロパティかと思っていました。
だからこそ、親コンポーネントの style の影響から逃れられる Portal が導入されたんだ。
Portal を使って、先ほどのコードを書き直すと、こんな感じになる。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="theme-color" content="#000000" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
<title>React App</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
<!-- modalでマウントするためのdivタブを追加 -->
<div id="modal"></div>
</body>
</html>
// Modal用のラップコンポーネント
import React from "react";
import ReactDOM from "react-dom";
type Props = {
children: React.ReactChild;
};
export const WrapPortal: React.VFC<Props> = ({ children }) => {
const el = document.getElementById("modal");
return ReactDOM.createPortal(children, el);
};
import React, { useState } from "react";
import { Alert, Button } from "react-bootstrap";
import { Modal } from "./Modal";
// WrapPortalをimport
import { WrapPortal } from "./WrapPortal";
export const Parent: React.VFC = () => {
const [show, setShow] = useState<boolean>(false);
const handleClose = (): void => setShow(false);
const handleShow = (): void => setShow(true);
return (
<Alert variant="primary" style={{ position: "relative", zIndex: 100 }}>
<p>親コンポーネント</p>
<p>z-index:100</p>
<Button variant="success" onClick={handleShow}>
モーダルウィンドウを開く
</Button>
{/* WrapPortalのchildrenとしてModalを設置 */}
<WrapPortal>
<Modal show={show} handleClose={handleClose} />
</WrapPortal>
</Alert>
);
};
固定コンポーネントよりも、モーダルウィンドウの方が上に表示されていますね。
モーダルウィンドウが親コンポーネントとは別の DOM にマウントしたから、親コンポーネントの z-index プロパティの影響を受けなくなった恩恵だね。