ポータルは、Reactのツリーでは同じ場所にとどまったまま、コンポーネントの一部をDOMの別の場所(通常は document.body)に描画します。react-dom の createPortal(children, domNode) で作ります。モーダル、ツールチップ、ドロップダウンメニューは、ポータルによって overflow: hidden や独自の重ね合わせコンテキストを持つ親から抜け出します。
下のボックスは、はみ出たものをすべて切り取ります。両方のヒントを開いてください。
普通のヒントは点線の枠で切れてしまいます。ポータルのヒントは全体が表示されます。そのDOMノードがボックスではなく <body> の子だからです。ボックスから overflow: 'hidden' を削除すると、普通のヒントも切り取られなくなります。
構文
import { createPortal } from 'react-dom';
createPortal(children, domNode, key?)
childrenは任意のJSXです。要素、フラグメント、コンポーネントなどです。domNodeは、document.bodyやdocument.getElementById('modal-root')のような既存のDOM要素です。ポータルが描画されるときに存在している必要があります。keyは省略可能で、ポータルのリストを描画するときに使います。
createPortal は、ほかの要素と同じようにJSXに入れるものを返します。親自身のDOMの位置には何も描画しません。
document.bodyに描画するモーダル
典型的な例はモーダルです。transform を持つカードの中では、position: fixed のオーバーレイはウィンドウではなくそのカードを基準に配置され、さらにカードの overflow: hidden によって切り取られます。document.body に描画すれば、ビューポート全体を覆います。
モーダルを開き、Escapeを押すか暗いオーバーレイをクリックして閉じてください。開くとフォーカスはCloseボタンに移ります。次に、createPortal を使わずにオーバーレイを返してみてください(呼び出しとその document.body 引数を取り除きます)。transform によってカードがfixed要素の包含ブロックになるので、オーバーレイはカードの大きさに縮みます。
イベントはReactのツリーを通って伝わる
ポータルが変えるのはDOMノードの置き場所であって、コンポーネントの位置ではありません。ReactのイベントはReactのツリーを上へ伝わるので、DOMではボタンが <body> の子であっても、ポータルの中のクリックはそれを描画したコンポーネントの onClick に届きます。
ネイティブのリスナーは違います。addEventListener で追加したリスナーはDOMのツリーに従い、そのクリックを受け取ることはありません。
「Inside the wrapper」をクリックすると、Reactのハンドラとネイティブのリスナーの両方がログを出します。「In a portal」をクリックすると、Reactのハンドラだけがログを出します。ポータルのボタンは画面上では点線のラッパーの外にありますが、Reactはそれを子として扱い続けます。
これはたいてい望ましい動きです。メニューの親の onClick はポータルの項目のクリックを受け取り、コンポーネントより上のコンテキストプロバイダーもポータルの中に適用されます。ただし「外側をクリックしたら閉じる」ロジックでは驚くことがあります。メニューを閉じるラッパーの onClick は、メニューのポータルの中のクリックでも発生するので、メニューの中で伝わりを止める(e.stopPropagation()、イベントのページで紹介しています)か、document のネイティブのリスナーを使って、ターゲットがポータルのDOMノードの中にあるかを確認してください。
自分用のコンテナへのポータル
document.body が一番簡単な描画先です。すべてのオーバーレイが1つの場所と1つの重なり順を共有するように、index.html に専用のコンテナを追加するアプリもあります。
<body>
<div id="root"></div>
<div id="modal-root"></div>
</body>
createPortal(<Modal />, document.getElementById('modal-root'));
サーバーレンダリング(Next.jsなどのフレームワーク)では、サーバーに document は存在しません。ポータルはコンポーネントがマウントされた後にだけ描画してください。たとえば、エフェクトが true にする mounted stateの後ろに置きます。
ポータルをきっかけとなる要素の隣に配置する必要があるときは、先にその要素を測ります。最初の例ではクリックハンドラの中で測っています。ちらついてはいけないツールチップなら、ブラウザが描画する前に実行されるuseLayoutEffectの中で測ってください。
アクセシブルなモーダル
マークアップを document.body に移しただけでは、キーボードやスクリーンリーダーのユーザーには何の助けにもなりません。モーダルには次のものも必要です。
role="dialog"とaria-modal="true"、そしてタイトルを指すaria-labelledby。- 開いたときにダイアログの中へフォーカスを移し(例ではCloseにフォーカスしています)、閉じたときにそれを開いたボタンへ戻すこと。
- Escapeで閉じられること。
- 開いている間はフォーカスを中にとどめ、Tabで後ろのページへ移動しないようにすること。モーダルが開いている間、アプリのルートに
inert属性を設定すると、そこでのフォーカスとクリックを防げます。
ネイティブの <dialog> 要素は、このほとんどを代わりにやってくれます。dialogRef.current.showModal() で開くと、ブラウザのトップレイヤーで、どのz-indexよりも上に描画され、ページの残りの部分を操作不能にし、Escapeで閉じます。ポータルが不要なので、簡単な確認ダイアログには最適な選択肢です。ポータルは、ツールチップ、メニュー、独自のオーバーレイのための道具であり続けます。
z-indexだけでは足りない理由
大きな z-index でメニューをページのほかの部分より上に出せなかった後に、ポータルに手を伸ばす開発者はよくいます。原因は重ね合わせコンテキストです。position と z-index を持つ要素、1未満の opacity、transform、filter、isolation: isolate を持つ要素は新しい重ね合わせコンテキストを作り、その子の z-index の値は、その中でお互いと競うだけになります。z-index: 1 のカードの中にある z-index: 9999 の子は、z-index: 2 を持つ兄弟のカードより下にあるままです。
ポータルは、要素をそうしたすべての祖先の外へ移します。<body> の子なら、その z-index はページのトップレベルの要素と比べられるので、オーバーレイには 1000 くらいの控えめな値で十分です。
よくある間違い
まだ存在しないノードにポータルを描画する。要素がなければ document.getElementById('modal-root') は null を返し、createPortal は「Target container is not a DOM element」を投げます。HTMLを確認するか、document.body にポータルを描画してください。
レンダリング中に描画先のノードを作る。コンポーネント本体で document.createElement('div') と書くと、レンダリングのたびに新しいノードが作られます。エフェクトで1回だけ作るか、固定のコンテナを使ってください。
きっかけの要素についていかないポップアップ。getBoundingClientRect() から配置したツールチップは、ページがスクロールしたり大きさが変わったりしても、きっかけの要素についていきません。scroll と resize で計算し直す(エフェクトのクリーンアップでそれらのリスナーを削除します)か、ページがスクロールしたらツールチップを閉じてください。
よくある質問
Reactのポータルとは何ですか?
子を、親コンポーネントのDOM要素の外にあるDOMノードに描画する方法です。react-dom の createPortal(children, domNode) で作ります。子はReactのツリーでの位置を保つので、props、state、コンテキストはいつもどおりに動きます。
ポータルはいつ使うべきですか?
何かをコンテナの上や外に表示する必要があるときです。モーダル、ツールチップ、ドロップダウンメニュー、トーストなどです。そうしないと、overflow: hidden、transform、独自の重ね合わせコンテキストを持つ親が、それを切り取ったり隠したりしてしまいます。
イベントはポータルの外へ伝わりますか?
はい、Reactのツリーを通って伝わります。ポータルの中のクリックは、DOMノードが document.body にあっても、Reactの親の onClick ハンドラに届きます。addEventListener で追加したネイティブのリスナーは、代わりにDOMのツリーに従います。
ポータルの中でもコンテキストは使えますか?
はい。コンテキストもイベントと同じように、Reactのツリーに従います。document.body に描画したモーダルも、それを作ったコンポーネントより上にあるプロバイダーから、テーマやユーザーを読めます。
モーダルにはポータルが必要ですか?
必ずしも必要ではありません。showModal() で開いたネイティブの <dialog> 要素は、ブラウザのトップレイヤーで、どのz-indexよりも上に描画され、フォーカスの扱いも組み込まれています。ポータルは、独自のモーダルや、ツールチップ、メニューのための一般的な選択肢です。