React Portals
1. React Portalsとは?
React Portalsは、親コンポーネントのDOM階層の外側にHTMLをレンダリングするための手段を提供します。
これは、モーダル、ツールチップ、ダイアログなど、コンテナのレイアウト(CSSの制約など)を飛び越えて表示させる必要があるコンポーネントにおいて特に有用です。
Portalは react-dom パッケージに含まれるReactのメソッドです。通常、コンポーネントから返されるHTML要素は親コンポーネントの子要素として配置されます。
createPortalメソッドを使用しない場合の例:
function myChild() {
return (
<div>
ようこそ
</div>
);
}しかし、createPortal メソッドを使用することで、HTMLを親コンポーネントの子要素としてではなく、DOM階層外の任意の場所にレンダリングすることが可能になります。
2. createPortalの基本
createPortal メソッドを使用した実装は以下の通りです。
実装例:
import { createPortal } from 'react-dom';
function myChild() {
return createPortal(
<div>
ようこそ
</div>,
document.body
);
}2.1 構文(Syntax)
import { createPortal } from 'react-dom';
createPortal(children, domNode)- children: 要素、文字列、フラグメント(Fragment)など、レンダリング可能なすべてのReactコンテンツ。
- domNode: ポータルを挿入する先のDOM要素。
3. Portalを使用したモーダルの作成
前述の通り、Portalはモーダル、ツールチップ、ダイアログといった「コンテナのレイアウト制約を突破」する必要があるコンポーネントに最適です。
以下は、親コンポーネントのDOM階層外にレンダリングされるモーダルコンポーネントの実装例です。
実装例:
import { createRoot } from 'react-dom/client';
import { useState } from 'react';
import { createPortal } from 'react-dom';
function Modal({ isOpen, onClose, children }) {
if (!isOpen) return null;
return createPortal(
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<div style={{
background: 'white',
padding: '20px',
borderRadius: '8px'
}}>
{children}
<button onClick={onClose}>閉じる</button>
</div>
</div>,
document.body
);
}
function MyApp() {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<h1>マイ・アプリ</h1>
<button onClick={() => setIsOpen(true)}>
モーダルを開く
</button>
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
<h2>モーダルのコンテンツ</h2>
<p>このコンテンツはMyAppコンポーネントの外側にレンダリングされています!</p>
</Modal>
</div>
);
}
createRoot(document.getElementById('root')).render(
<MyApp />
);3.1 コードの解説
- インポート:
react-domからcreatePortalを、reactから状態管理用のuseStateをインポートします。 - Modalコンポーネント:
createPortalを使用して、そのコンテンツをdocument.body要素に直接レンダリングします。
4. なぜPortalを使用するのか
Portalは以下のようなケースで非常に役立ちます:
- モーダルやダイアログ
- ツールチップ
- フローティングメニュー
- 通知(Notification)
特に、親コンポーネントに以下の設定がある場合、UI要素がコンテナ内に閉じ込められてしまうのを防ぐためにPortalが必要です:
overflow: hiddenz-indexの競合- 複雑なポジショニング(配置)要件
5. Portalにおけるイベントバブリング
PortalがDOMツリーの異なる場所にコンテンツをレンダリングしたとしても、Portal内のコンテンツから発生したイベントは、あたかもPortalが存在しないかのように Reactコンポーネントツリーに従ってバブリング します。
例えば、Portal内のボタンがクリックされた場合、そのイベントはReactの階層上の親コンポーネントへと伝播し、親のイベントハンドラーをトリガーします。
実装例:
import { createRoot } from 'react-dom/client';
import { useState } from 'react';
import { createPortal } from 'react-dom';
function PortalButton({ onClick, children }) {
return createPortal(
<button
onClick={onClick}
style={{
position: 'fixed',
bottom: '20px',
right: '20px',
padding: '10px',
background: 'blue',
color: 'white'
}}>
{children}
</button>,
document.body
);
}
function App() {
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
return (
<div
style={{
padding: '20px',
border: '2px solid black',
margin: '20px'
}}
onClick={() => {
// 親要素のクリックハンドラー
setCount1(c => c + 1);
}}>
<h2>Divのクリック回数: {count1}</h2>
<h2>ボタンのクリック回数: {count2}</h2>
<p>このフローティングボタンはPortalを使用して枠の外側にレンダリングされています。
しかし、クリックイベントはこの親のdivまでバブリングされます!</p>
<p>div要素自体をクリックして、カウントが増えるのも確認してみてください。</p>
<PortalButton
onClick={(e) => {
// これが最初に実行されます
setCount2(c => c + 1);
}}>
フローティングボタン
</PortalButton>
</div>
);
}
createRoot(document.getElementById('root')).render(
<App />
);5.1 コードの解説
この例では以下の挙動が確認できます:
PortalButtonはPortalを用いて画面の右下に固定(fixed)表示されます。- DOM上では親の
<div>外に存在しますが、ボタンをクリックすると: - まず、ボタン自身の
onClickハンドラーが実行されます。 - 次に、親である
divのonClickハンドラーが実行されます。
これは、イベントバブリングが物理的な「DOM階層」ではなく、Reactの「コンポーネント階層」に従って機能することを証明しています。